From 815bac7de9c9b6fcc9e51ca93b6c6422f3c56d48 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:03:57 +0800 Subject: [PATCH 01/87] refactor(session): drop the dead mutable SessionSummary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update() were dead state: zero production callers of update(), no production reader of updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not storage. The live Session.header was already typed SessionHeader, so the summary only ever existed in the persistence layer, written and read by nothing but its own contract test. Delete it entirely (no SessionMeta alias — SessionMeta collapses to SessionHeader everywhere). This removes the JSONL .summary.json sidecar machinery, the SQLite title/first_prompt/updated_at columns and per-append updated_at bump, and the update() method from the abstract service and both backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any non-current user_version (older or newer) — no migration, unreleased software. Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability divergence that the upcoming write coordinator would otherwise have to model. Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md and migrates the 2026-06-14 session-persistence RFC's facts to current truth. Adds a standalone AGENTS.md section "Tests document behavior, not golden truth" (a passing test pins current behavior, not necessarily correct behavior) with the summary-drop as its worked example, and reinforces the no-migration pre-release stance. --- AGENTS.md | 10 ++ docs/architecture.md | 4 +- docs/rfc/README.md | 1 + .../2026-06-14-session-persistence.md | 6 +- ...2026-06-19-drop-mutable-session-summary.md | 31 ++++ packages/acp/tests/load.spec.ts | 4 +- packages/session-persistence-jsonl/README.md | 3 +- .../session-persistence-jsonl/src/format.ts | 33 +--- .../session-persistence-jsonl/src/index.ts | 156 +++--------------- .../tests/jsonl.spec.ts | 150 +---------------- packages/session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 66 +++----- .../session-persistence-sqlite/src/schema.ts | 36 ++-- .../tests/sqlite.spec.ts | 63 +++---- packages/session-persistence/README.md | 9 +- packages/session-persistence/src/index.ts | 24 +-- .../session-persistence/tests/contract.ts | 35 +--- .../tests/persistence.spec.ts | 17 +- packages/session/README.md | 6 +- packages/session/src/types.ts | 23 --- 20 files changed, 163 insertions(+), 518 deletions(-) create mode 100644 docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md diff --git a/AGENTS.md b/AGENTS.md index 0533c4cf0a..1bb32121e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,16 @@ This is the monorepo for the DeepSeek Harness group. It currently hosts the code **This applies only while the harness is unreleased — remove this section at the first tagged/published release.** There are no external consumers yet, so optimize for the *correct foundation*, not for a small diff. When the right structure means moving a file across package boundaries, renaming a public symbol, or repackaging a plugin, do it — and update every reference in the same change. Do **not** add backward-compat shims, deprecation aliases, re-export stubs, or "keep it where it is to avoid churn" hedges; those are debts you take on to protect callers you do not have. Churn now is cheap; a wrong foundation set in stone is not. (Once released, this inverts — backward compatibility becomes a real constraint and this section comes out.) +This extends to **on-disk formats, schemas, and stored data**: while unreleased there is no persisted user data to preserve, so a format/schema/contract change needs **no migration path**. Bump the version and reject (don't migrate) anything not at the current version — e.g. the SQLite backend's `SCHEMA_VERSION` bump that drops columns simply rejects any non-current `user_version` on open, with no v1→v2 migration. A migration written now is a shim for data that does not exist. + +## Tests document behavior, not golden truth + +A passing test pins the behavior the code **currently** has — not necessarily the behavior it **should** have. Existing tests faithfully document existing behavior, but existing behavior is not automatically golden: it can be the residue of a past compromise, a half-built feature, or a limitation that no longer applies. So when a refactor or review makes you ask "can I change this?", a green test is **not** the answer — the question is whether the behavior the test pins is actually correct. + +Before you preserve a behavior solely to keep a test green, ask: is this behavior load-bearing (a real consumer depends on it, a contract promises it, a user observes it), or is it an artifact? If it's an artifact, **change the behavior AND its test together, in the same change, and say why in the PR** — do not contort new code to keep an obsolete assertion passing, and do not treat "but the test expects X" as a reason X must stay. Conversely, do not delete a test just because it is inconvenient: the discipline cuts both ways — you must show the *behavior* is dead, not merely that the test is in your way. + +The worked example is [Drop the mutable session summary](docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet nothing in production read or wrote any of it. The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) + ## Architecture This codebase is based on the **Cordis** framework, built microkernel-style: **everything is a plugin**. All necessary Cordis dependencies are copied into this monorepo as vendored source (under `vendor/`) instead of being depended on via npm. diff --git a/docs/architecture.md b/docs/architecture.md index ef143153bb..81a478b714 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` |---|---|---|---| | `ctx.llm` | `LlmService` | dsh-llm | adapter registry; `stream()` / `streamBlocks()` / `generate()` | | `ctx.sessions` | `SessionStore` | dsh-session | creates/holds event-sourced `Session`s | -| `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list/update sessions | +| `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions | | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall | | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam | @@ -85,7 +85,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry = listen to `session/event`. -**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The durable backend is a real **capability seam**: the abstract `SessionPersistence` service (`dsh-session-persistence`, `ctx.sessionPersistence`) defines create/append/load/list/update over the existing `SessionEvent` (no parallel persisted type), and `dsh-session-persistence-jsonl` is the first implementation — an append-only JSONL log per session with crash-safe atomic writes, crash recovery that PRESERVES an interrupted turn (closing it with a synthetic `turn/end {interrupted}` rather than truncating — a turn can be huge), and a read/replay path. Session metadata (format version, cwd, lineage) travels separately as `SessionMeta`, attached to a `Session` via `session.header`. Resuming a persisted session into a live agent is `ctx.agents.resume({ resumeSessionId })`. A second backend, `dsh-session-persistence-sqlite` (`node:sqlite`, one row per `SessionEvent` — the row shape `(session_id, seq, type, time, data)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic. +**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The durable backend is a real **capability seam**: the abstract `SessionPersistence` service (`dsh-session-persistence`, `ctx.sessionPersistence`) defines create/append/load/list over the existing `SessionEvent` (no parallel persisted type), and `dsh-session-persistence-jsonl` is the first implementation — an append-only JSONL log per session with crash-safe atomic writes, crash recovery that PRESERVES an interrupted turn (closing it with a synthetic `turn/end {interrupted}` rather than truncating — a turn can be huge), and a read/replay path. Session metadata (format version, cwd, lineage) travels separately as `SessionHeader`, attached to a `Session` via `session.header`. Resuming a persisted session into a live agent is `ctx.agents.resume({ resumeSessionId })`. A second backend, `dsh-session-persistence-sqlite` (`node:sqlite`, one row per `SessionEvent` — the row shape `(session_id, seq, type, time, data)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic. ## Prompt assembly (dsh-system-prompt) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 48e99d37b8..bc915f2292 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -60,6 +60,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [ACP snapshot tests — record-once / replay-deterministic](implemented/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | +| [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-14-session-persistence.md b/docs/rfc/implemented/2026-06-14-session-persistence.md index e676181465..3e66be3730 100644 --- a/docs/rfc/implemented/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/2026-06-14-session-persistence.md @@ -16,15 +16,15 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`/`has`/`delete`/`update`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. -2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**) plus an atomic `.summary.json` sidecar for the mutable `SessionSummary`. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`/`has`/`delete`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**). Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. -- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionMeta` (`SessionHeader & SessionSummary`) owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. +- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](2026-06-19-drop-mutable-session-summary.md).) - **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. Format versioning: the header carries a `version`; `load` rejects an unknown version (no v1 migration). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. diff --git a/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md new file mode 100644 index 0000000000..f2b82fe2f9 --- /dev/null +++ b/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md @@ -0,0 +1,31 @@ +# RFC: Drop the mutable session summary + +Status: implemented (proposed and accepted 2026-06-19) + +## Context + +The [session-persistence seam](2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMeta = SessionHeader & SessionSummary`, and the abstract `SessionPersistence` service carried a seventh method — `update(id, summary)` — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic `.summary.json` **sidecar** beside the log (temp-write + rename, best-effort), SQLite kept `updated_at`/`title`/`first_prompt` **columns** bumped inside the append transaction. + +The summary was designed for a future session picker (recency ordering via `updatedAt`, a `title`/`firstPrompt` preview). That picker was never built. An audit of the whole repo found the entire `SessionSummary` surface is **dead state**: + +- `SessionPersistence.update()` has **zero production callers** (every `.update(` hit is `createHash().update()` or a test). +- `firstPrompt` is **never read** anywhere in production. +- `title` *is* read in the ACP bridge — but from a tool-call **presenter** (`present.title`), never from stored session metadata. +- `updatedAt` has **no consumer**: the only production caller of `list()` reads `meta.cwd` (a `SessionHeader` field) to validate a workspace on `session/load`; resume reads `createdAt`/`cwd`/`parentSession` — all header fields. +- Decisively: the live `Session.header` was already typed `SessionHeader`, not `SessionMeta` — the summary never existed on the live session object; it lived only in the persistence layer, written and read by nothing but its own contract test. + +## Decision + +Delete the mutable session summary entirely. `SessionSummary` and the `SessionMeta` name are removed; the metadata a backend stores and returns is just `SessionHeader`. `SessionPersistence.update()` is removed from the abstract service and every backend. JSONL loses the whole sidecar machinery (`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` and the load/list overlays); SQLite drops the `updated_at`/`title`/`first_prompt` columns and the per-append `updated_at` bump, and its `SCHEMA_VERSION` goes `1 → 2`. + +Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it. + +This is recorded as a decision because it is **durable** (it narrows a public service contract and an on-disk format across two backends), **contested** (the summary was a deliberate forward-looking design, not an accident), and **surprising** (a future reader finding `SessionHeader` where the original RFC describes `SessionMeta` would otherwise ask why the summary vanished). It also unblocks the [shared persistence write coordinator](../proposed/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. + +## No migration + +This is unreleased software (see [root AGENTS.md](../../../AGENTS.md) § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the `openDatabase` guard now rejects any non-current on-disk `user_version` (`onDisk !== 0 && onDisk !== SCHEMA_VERSION`) — older *or* newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work. + +## What we gave up + +A future session picker now has to derive its preview/ordering from the log (or reintroduce a typed field) rather than reading a ready-made summary row. That is the correct cost: a cache for a feature that does not exist is dead weight that every backend pays to maintain and every contract test pays to assert. The principle — **a passing test pins current behavior, not necessarily correct behavior; behavior can be an artifact of a past compromise** — is now recorded as a standalone convention in [root AGENTS.md](../../../AGENTS.md), with this change as its worked example. diff --git a/packages/acp/tests/load.spec.ts b/packages/acp/tests/load.spec.ts index f06c707d85..b1e4acfda5 100644 --- a/packages/acp/tests/load.spec.ts +++ b/packages/acp/tests/load.spec.ts @@ -166,7 +166,7 @@ describe('acp bridge — session/load replay', () => { loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, updatedAt: 1, + version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, }) await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -203,7 +203,7 @@ describe('acp bridge — session/load replay', () => { // to the server's launch dir (the request cwd does not override the header). loader = await makeBridgeHarness({ storageDir, script: [] }) await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('legacy'), createdAt: 1, updatedAt: 1, // no cwd + version: 1, id: SessionId('legacy'), createdAt: 1, // no cwd }) await loader.ctx.sessionPersistence.append(SessionId('legacy'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence-jsonl/README.md index ee83eb2927..c012886099 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence-jsonl/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence-jsonl -The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). One append-only `.jsonl` event log per session plus a small atomic `.summary.json` sidecar for mutable metadata. +The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). One append-only `.jsonl` event log per session. ## On-disk layout @@ -8,7 +8,6 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence / cwd-/ # per-project bucket (or _no-cwd/ when no cwd) .jsonl # header line + one SessionEvent per line (verbatim) - .summary.json # mutable SessionSummary (atomic temp-write + rename) ``` - The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession? }`; every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). diff --git a/packages/session-persistence-jsonl/src/format.ts b/packages/session-persistence-jsonl/src/format.ts index 1a410d9b75..32258c6a37 100644 --- a/packages/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence-jsonl/src/format.ts @@ -2,15 +2,15 @@ * On-disk format helpers for the JSONL session-persistence backend: path * sanitization (a {@link SessionId} is an unvalidated branded string, so it * MUST be encoded before use in a path — no traversal, no collision), the - * per-cwd directory layout, header-line (de)serialization, the atomic sidecar - * for mutable summary fields, and the truncation-repair offset computation. + * per-cwd directory layout, header-line (de)serialization, and the + * truncation-repair offset computation. * * @module dsh-session-persistence-jsonl/format */ import { createHash } from 'node:crypto' import { join } from 'node:path' -import type { SessionEvent, SessionHeader, SessionId, SessionMeta } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' /** * The first line of a session's `.jsonl` file: the immutable @@ -109,11 +109,6 @@ export function logPath(root: string, cwd: string | undefined, id: SessionId): s return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`) } -/** The mutable-summary sidecar path for a session (beside its log). */ -export function sidecarPath(root: string, cwd: string | undefined, id: SessionId): string { - return join(sessionDir(root, cwd), `${encodeSegment(id)}.summary.json`) -} - /** Serialize one event as a JSONL line (no trailing newline). */ export function eventLine(event: SessionEvent): string { return JSON.stringify(event) @@ -138,7 +133,7 @@ export function eventLine(event: SessionEvent): string { * (`Session.append` enforces it): only the final turn can be open, so the * preserved tail is at most one unclosed turn. */ -export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEvent[]; committedBytes: number } { +export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { const text = buffer.toString('utf8') // Split into complete (newline-terminated) lines, tracking the byte offset of // each line's end so the truncation point is exact (multi-byte chars make the @@ -233,26 +228,16 @@ export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEve // synthetic closers + new events. const lastPreserved = parsed[preserved.length - 1] const committedBytes = preserved.length > 0 && lastPreserved ? lastPreserved.endByte : headerEntry.endByte - return { meta: metaFrom(headerLine), events: preserved, committedBytes } -} - -/** Build the load-time {@link SessionMeta} from a header line (summary overlaid later). */ -function metaFrom(headerLine: HeaderLine): SessionMeta { - return { - ...fromHeaderLine(headerLine), - updatedAt: headerLine.createdAt, // overlaid by the sidecar in load() - } + return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes } } /** - * Parse just the header line of a log into load-time {@link SessionMeta}, or + * Parse just the header line of a log into a {@link SessionHeader}, or * `undefined` if it is missing/not a header. Used by `list()` to read session * metadata WITHOUT parsing the whole log: a session picker scales with the - * number of sessions, not the total size of every conversation. The summary - * sidecar is overlaid by the caller; `updatedAt` here mirrors `createdAt` until - * then (same as {@link scanLog}'s load-time meta). + * number of sessions, not the total size of every conversation. */ -export function parseHeaderMeta(firstLine: string): SessionMeta | undefined { +export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { let parsed: unknown try { parsed = JSON.parse(firstLine) @@ -260,5 +245,5 @@ export function parseHeaderMeta(firstLine: string): SessionMeta | undefined { return undefined } if (!isHeaderLine(parsed)) return undefined - return metaFrom(parsed) + return fromHeaderLine(parsed) } diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index 7a0c97637c..110569d3e3 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -5,8 +5,7 @@ * * 1. **The backend** — a concrete {@link SessionPersistence}: one append-only * `.jsonl` event log per session (a header line then one `SessionEvent` per - * line, verbatim including `assistant/chunk` so `seq` stays contiguous) plus - * a small atomic `.summary.json` sidecar for the mutable `SessionSummary`. + * line, verbatim including `assistant/chunk` so `seq` stays contiguous). * Lazy materialization (no file until the first `append`), atomic first * write, and load-time repair of a never-committed crash tail. * @@ -22,16 +21,16 @@ import { Context } from 'cordis' import z from 'schemastery' -import { open, mkdir, readFile, readdir, rename, link, rm, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, assertSerializable, seedCoversPrefix, } from '@deepseek-ai/dsh-session-persistence' import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, sidecarPath, toHeaderLine, + encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, } from './format.ts' export interface Config { @@ -45,7 +44,7 @@ export interface Config { /** Per-session write state held by the backend's in-memory bookkeeping. */ interface SessionState { - meta: SessionMeta + meta: SessionHeader /** The next seq the backend expects to append (the stored log length). */ cursor: number /** Whether the `.jsonl` file has been physically materialized. */ @@ -130,17 +129,17 @@ export class SessionPersistenceJsonl extends SessionPersistence { // --- SessionPersistence backend surface (all serialized per session id) --- - create(meta: SessionMeta): Promise { + create(meta: SessionHeader): Promise { // Snapshot the metadata at call time: the op runs later (behind the // per-session chain) and the snapshot is also stored as the lazy state, so // keeping the caller's object by reference would let a later mutation of // `id`/`cwd` register under one key but materialize under a different - // path/header. A shallow copy is enough — SessionMeta is a flat record. - const snapshot: SessionMeta = { ...meta } + // path/header. A shallow copy is enough — SessionHeader is a flat record. + const snapshot: SessionHeader = { ...meta } return this.serialize(snapshot.id, () => this.createCore(snapshot)) } - private async createCore(meta: SessionMeta): Promise { + private async createCore(meta: SessionHeader): Promise { // Do NOT clobber an existing session. If we already track it, or a log // exists on disk under this id, refuse — the SessionId IS the identity, and // silently resetting state (cursor 0, materialized false) over committed @@ -218,18 +217,15 @@ export class SessionPersistenceJsonl extends SessionPersistence { await this.appendLines(state, events) } // The durable event log is the transaction: advance the cursor as soon as - // the log write commits. The sidecar (mutable summary) is best-effort here - // — a failed sidecar write must NOT reject an append whose log already - // landed (that would desync the cursor and let a retry duplicate seqs). + // the log write commits. state.cursor += events.length - await this.touchSummary(state).catch(() => { /* sidecar is recoverable metadata; log is durable */ }) } - load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.serialize(id, () => this.loadCore(id)) } - private async loadCore(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const cwd = this.states.get(id)?.meta.cwd const file = await this.findLog(id, cwd) if (file === undefined) throw new Error(`session "${id}" not found`) @@ -237,9 +233,6 @@ export class SessionPersistenceJsonl extends SessionPersistence { const { meta, events, committedBytes } = scanLog(buffer) this.assertVersion(meta) - const summary = await this.readSidecar(id, meta.cwd) - const fullMeta: SessionMeta = { ...meta, ...summary } - // Crash-recovery: if the log ended mid-turn (an open turn with real, // preserved events but no closing turn/end), close it durably DURING load so // disk, the returned log, and the cursor all agree — both append routes then @@ -253,7 +246,7 @@ export class SessionPersistenceJsonl extends SessionPersistence { // Set state BEFORE the repair writes so they can resolve the log path. const needsTorn = committedBytes < buffer.byteLength const state: SessionState = { - meta: { ...fullMeta }, + meta: { ...meta }, cursor: events.length, materialized: true, } @@ -267,15 +260,12 @@ export class SessionPersistenceJsonl extends SessionPersistence { if (closers.length > 0) { // Durably append the synthetic closers, then advance the cursor to the // balanced length. After this, disk == balanced and the next append (live - // or direct) continues cleanly. No sidecar touch here: load is not a - // summary-changing op (the closers carry no new title/firstPrompt), and - // the next real append bumps `updatedAt` — keeping the summary write off - // the recovery path avoids a second best-effort failure mode. + // or direct) continues cleanly. await this.appendLines(state, closers) state.cursor = balanced.length } - return { meta: fullMeta, events: balanced } + return { meta, events: balanced } } private async adoptLiveDiskPrefix( @@ -290,9 +280,8 @@ export class SessionPersistenceJsonl extends SessionPersistence { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } - const summary = await this.readSidecar(session.header.id, meta.cwd) const state: SessionState = { - meta: { ...meta, ...summary }, + meta: { ...meta }, cursor: events.length, materialized: true, owner: session, @@ -306,8 +295,8 @@ export class SessionPersistenceJsonl extends SessionPersistence { if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } - async list(): Promise { - const metas: SessionMeta[] = [] + async list(): Promise { + const metas: SessionHeader[] = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { // Read ONLY the header line, not the whole log: a session picker must @@ -318,8 +307,7 @@ export class SessionPersistenceJsonl extends SessionPersistence { if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header - const summary = await this.readSidecarForList(meta.id, meta.cwd) - metas.push({ ...meta, ...summary }) + metas.push(meta) } } return metas @@ -367,41 +355,9 @@ export class SessionPersistenceJsonl extends SessionPersistence { const cwd = this.states.get(id)?.meta.cwd const file = await this.findLog(id, cwd) if (file) await rm(file.path, { force: true }) - // Remove the sidecar too. A lazy session (update() before the first - // append()) has a `.summary.json` sidecar but NO `.jsonl` log, and after a - // restart the in-memory cwd is gone — so keying sidecar removal off the log - // or the in-memory cwd would leak its possibly-sensitive title/firstPrompt. - // Scan every cwd bucket for the sidecar by its (sanitized) filename. - await this.removeSidecars(id) this.states.delete(id) } - /** Remove a session's summary sidecar from EVERY cwd bucket (id is unique). */ - private async removeSidecars(id: SessionId): Promise { - const target = `${encodeSegment(id)}.summary.json` - for (const dir of await this.listCwdDirs()) { - await rm(`${dir}/${target}`, { force: true }) - } - } - - update(id: SessionId, summary: Partial): Promise { - return this.serialize(id, () => this.updateCore(id, summary)) - } - - private async updateCore(id: SessionId, summary: Partial): Promise { - let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) - // Build the NEXT meta separately and commit it to in-memory state only AFTER - // the sidecar write succeeds. update's only durable effect is the sidecar, - // so a failure DOES reject (unlike append, whose log is the transaction and - // sidecar is best-effort) — but if we mutated state.meta first, a later - // touchSummary() on a successful append would persist the rejected - // title/firstPrompt, making a failed update durable after the fact. - const nextMeta: SessionMeta = { ...state.meta, ...summary, updatedAt: summary.updatedAt ?? Date.now() } - if (state.materialized) await this.writeSidecar(nextMeta) - state.meta = nextMeta - } - // --- materialization / append / repair --- /** Atomically write the header line + first batch (temp-write, fsync, rename). */ @@ -521,76 +477,6 @@ export class SessionPersistenceJsonl extends SessionPersistence { } } - // --- sidecar (mutable summary) --- - - private async touchSummary(state: SessionState): Promise { - state.meta = { ...state.meta, updatedAt: Date.now() } - await this.writeSidecar(state.meta) - } - - /** - * Atomic sidecar write (temp-write + rename), summary fields only. - * - * Deliberately NOT directory-fsynced (unlike {@link materialize}): the - * sidecar holds mutable, recoverable summary metadata (updatedAt, title, - * firstPrompt), not source-of-truth log data. The rename is atomic so a - * reader never sees a torn file, but a power loss may lose the most recent - * summary — acceptable because it is re-derivable and the durable log (the - * transaction) is independently synced. Strict crash-durability is reserved - * for the event log. - */ - private async writeSidecar(meta: SessionMeta): Promise { - const dir = sessionDir(this.root, meta.cwd) - await mkdir(dir, { recursive: true, mode: 0o700 }) - const path = sidecarPath(this.root, meta.cwd, meta.id) - const summary: SessionSummary = { - updatedAt: meta.updatedAt, - ...meta.title !== undefined ? { title: meta.title } : {}, - ...meta.firstPrompt !== undefined ? { firstPrompt: meta.firstPrompt } : {}, - } - const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp` - // Exclusive owner-only create ('wx', 0o600), matching the log-materialization - // temp write: the sidecar can carry user data (title/firstPrompt), so a - // predictable/pre-existing temp path must never be silently truncated and - // followed (symlink race / disclosure). The random suffix already makes a - // collision unlikely; 'wx' makes reuse an error rather than a clobber. - const handle = await open(tmp, 'wx', 0o600) - try { - await handle.writeFile(JSON.stringify(summary)) - } finally { - await handle.close() - } - await rename(tmp, path) - } - - /** - * Read the mutable-summary sidecar, or `undefined` if it is absent (a session - * that has never been `update()`d). Non-ENOENT failures surface on strict - * load/adopt paths so corrupt metadata does not masquerade as a clean default. - */ - private async readSidecar(id: SessionId, cwd: string | undefined): Promise { - try { - const raw = await readFile(sidecarPath(this.root, cwd, id), 'utf8') - return JSON.parse(raw) as SessionSummary - } catch (error) { - if (isENOENT(error)) return undefined - throw error - } - } - - /** - * Best-effort summary read for list(): a corrupt sidecar should degrade one - * row to header metadata, not hide every session from a picker. - */ - private async readSidecarForList(id: SessionId, cwd: string | undefined): Promise { - try { - return await this.readSidecar(id, cwd) - } catch (error: unknown) { - this.ctx.logger.warn(`session-persistence-jsonl: ignoring unreadable summary for session "${id}" while listing: ${String(error)}`) - return undefined - } - } - // --- discovery helpers --- /** Find a session's log file across cwd buckets (when cwd is unknown). */ @@ -657,7 +543,7 @@ export class SessionPersistenceJsonl extends SessionPersistence { return state } - private assertVersion(meta: SessionMeta): void { + private assertVersion(meta: SessionHeader): void { if (meta.version !== 1) { throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) } @@ -827,7 +713,7 @@ export class SessionPersistenceJsonl extends SessionPersistence { // case 4: a genuinely new session. Register its meta (lazy), then persist // its seed (events present at creation time) once. - const meta: SessionMeta = { ...session.header, updatedAt: Date.now() } + const meta: SessionHeader = { ...session.header } await this.create(meta) // Bind this state to the live session so a later DIFFERENT session reusing // the id is detected as a collision (case 1) rather than silently no-opped. diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence-jsonl/tests/jsonl.spec.ts index 8257a9853c..07cc470c6e 100644 --- a/packages/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence-jsonl/tests/jsonl.spec.ts @@ -4,9 +4,9 @@ import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, logPath, scanLog, sessionDir, sidecarPath } from '../src/format.ts' +import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' let root: string @@ -274,7 +274,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('path-traversal session ids are neutralized (no escape from root)', async () => { const evil = SessionId('../../etc/pwn') - const m = { version: 1, id: evil, createdAt: 1, updatedAt: 1 } + const m = { version: 1, id: evil, createdAt: 1 } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(evil, oneTurnLog()) // The file lives UNDER root, not at ../../etc. @@ -581,23 +581,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(await ctx.sessionPersistence.has(m.id)).toBe(false) }) - it('append resolves even when the best-effort sidecar write fails (log is the transaction)', async () => { - const m = meta('sidecar-fail') - await ctx.sessionPersistence.create(m) - // Force the sidecar write to reject AFTER the durable log append commits. - // The append must still resolve and advance the cursor — a failed sidecar - // is recoverable metadata and must never desync the log (which would let a - // retry duplicate seqs). This exercises the `.catch()` on touchSummary. - const backend = ctx.sessionPersistence as unknown as { writeSidecar: (state: unknown) => Promise } - const original = backend.writeSidecar.bind(backend) - backend.writeSidecar = () => Promise.reject(new Error('disk full')) - await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() - backend.writeSidecar = original - // The durable log landed in full despite the sidecar failure. - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) - }) - it('append rejects non-JSON-serializable undefined-producing data', async () => { const m = meta('undef') await ctx.sessionPersistence.create(m) @@ -610,87 +593,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined() }) - it('update adopts a session that exists only on disk', async () => { - const m = meta('disk-only') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - // A fresh backend has no in-memory state → update must adopt from disk. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - await ctx2.sessionPersistence.update(m.id, { title: 'adopted' }) - const loaded = await ctx2.sessionPersistence.load(m.id) - expect(loaded.meta.title).toBe('adopted') - await ctx2.fiber.dispose() - }) - - it('a failed update does not become durable via a later append', async () => { - const m = meta('update-fail') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - // Force the sidecar write to fail for the update. - const backend = ctx.sessionPersistence as unknown as { writeSidecar: (meta: unknown) => Promise } - const original = backend.writeSidecar.bind(backend) - backend.writeSidecar = () => Promise.reject(new Error('disk full')) - await expect(ctx.sessionPersistence.update(m.id, { title: 'rejected-title' })).rejects.toThrow(/disk full/) - backend.writeSidecar = original - // A later successful append's touchSummary must NOT persist the rejected - // title (it was never committed to in-memory state). - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[]) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.title).toBeUndefined() - }) - - it('update before the first append keeps summary in memory and writes no orphan sidecar', async () => { - const m = meta('lazy-update', '/a') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.update(m.id, { title: 'secret', firstPrompt: 'sensitive' }) - const sidecar = sidecarPath(root, '/a', m.id) - await expect(stat(sidecar)).rejects.toThrow() - await expect(stat(logPath(root, '/a', m.id))).rejects.toThrow() - - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.title).toBe('secret') - expect(loaded.meta.firstPrompt).toBe('sensitive') - expect((await stat(sidecar)).isFile()).toBe(true) - }) - - it('a lazy update leaves no sidecar that can leak into a future same-id session after restart', async () => { - await ctx.sessionPersistence.create(meta('restart-lazy', '/a')) - await ctx.sessionPersistence.update(SessionId('restart-lazy'), { title: 'secret' }) - await expect(stat(sidecarPath(root, '/a', SessionId('restart-lazy')))).rejects.toThrow() - - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - const m2 = meta('restart-lazy', '/a') - await ctx2.sessionPersistence.create(m2) - await ctx2.sessionPersistence.append(m2.id, oneTurnLog()) - const loaded = await ctx2.sessionPersistence.load(m2.id) - expect(loaded.meta.title).toBeUndefined() - await ctx2.fiber.dispose() - }) - - it('delete removes a materialized cwd-bucket sidecar after a restart', async () => { - await ctx.sessionPersistence.create(meta('restart-del', '/a')) - await ctx.sessionPersistence.append(SessionId('restart-del'), oneTurnLog()) - await ctx.sessionPersistence.update(SessionId('restart-del'), { title: 'secret' }) - const sidecar = sidecarPath(root, '/a', SessionId('restart-del')) - expect((await stat(sidecar)).isFile()).toBe(true) - - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - await ctx2.sessionPersistence.delete(SessionId('restart-del')) - await expect(stat(sidecar)).rejects.toThrow() - await expect(stat(logPath(root, '/a', SessionId('restart-del')))).rejects.toThrow() - await ctx2.fiber.dispose() - }) - it('an abandoned lazy session (never materialized) releases its id for reuse', async () => { // A live session is created then disposed BEFORE its first append: cursor 0, // never materialized, nothing on disk. A new live session reusing the id @@ -765,27 +667,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toEqual(['p1', 'p2', 'p3']) }) - it('list tolerates one corrupt sidecar and still returns other sessions', async () => { - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const bad = meta('bad-list-summary', '/proj') - await ctx.sessionPersistence.create(bad) - await ctx.sessionPersistence.append(bad.id, oneTurnLog()) - await ctx.sessionPersistence.update(bad.id, { title: 'hidden by corrupt sidecar' }) - await writeFile(sidecarPath(root, '/proj', bad.id), '{not json') - const good = meta('good-list-summary', '/proj') - await ctx.sessionPersistence.create(good) - await ctx.sessionPersistence.append(good.id, oneTurnLog()) - await ctx.sessionPersistence.update(good.id, { title: 'visible' }) - - const listed = await ctx.sessionPersistence.list() - - const badListed = listed.find(m => m.id === bad.id) - expect(badListed).toMatchObject({ id: bad.id }) - expect(badListed).not.toHaveProperty('title') - expect(listed.find(m => m.id === good.id)).toMatchObject({ id: good.id, title: 'visible' }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('bad-list-summary')) - }) - it('list on an empty root returns nothing', async () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) @@ -1062,36 +943,13 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('round-trips a header with parentSession (fork lineage)', async () => { - const m: SessionMeta = { version: 1, id: SessionId('forked-child'), createdAt: 1, updatedAt: 1, parentSession: SessionId('the-parent') } + const m: SessionHeader = { version: 1, id: SessionId('forked-child'), createdAt: 1, parentSession: SessionId('the-parent') } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) expect(loaded.meta.parentSession).toBe('the-parent') }) - it('loads a log that has no sidecar (default summary)', async () => { - // Hand-write a valid log WITHOUT a sidecar, then load it. - const dir = sessionDir(root, undefined) - await (await import('node:fs/promises')).mkdir(dir, { recursive: true }) - const header = JSON.stringify({ type: 'session', version: 1, id: 'no-sidecar', createdAt: 5 }) - const body = oneTurnLog().map(e => JSON.stringify(e)).join('\n') - await writeFile(logPath(root, undefined, SessionId('no-sidecar')), header + '\n' + body + '\n') - const loaded = await ctx.sessionPersistence.load(SessionId('no-sidecar')) - expect(loaded.events).toHaveLength(6) - expect(loaded.meta.title).toBeUndefined() // no sidecar → no title - // With no sidecar, updatedAt falls back to the header createdAt (5), NOT 0 - // — reporting an active session as updated at the Unix epoch would be wrong. - expect(loaded.meta.updatedAt).toBe(5) - }) - - it('load rejects a corrupt sidecar instead of treating it as absent', async () => { - const m = meta('bad-sidecar') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await writeFile(sidecarPath(root, undefined, m.id), '{not json') - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow() - }) - it('list returns nothing when the root directory does not exist', async () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) diff --git a/packages/session-persistence-sqlite/README.md b/packages/session-persistence-sqlite/README.md index 2025bd8a80..d821b56446 100644 --- a/packages/session-persistence-sqlite/README.md +++ b/packages/session-persistence-sqlite/README.md @@ -6,9 +6,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). Out-of-log metadata (`SessionMeta`) lives in a `sessions` row, including the mutable `SessionSummary` fields (`updatedAt`, `title`, `firstPrompt`) that `update()` rewrites without touching the event log. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`has`/`list` report exactly the sessions that have a row), so no separate column is needed. +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). 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 (`has`/`list` report 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 a newer, incompatible build (higher `user_version`) is rejected rather than opened against an unknown layout. +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). ## Contract semantics over rows diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence-sqlite/src/index.ts index d28fc8d6f7..34ed6213da 100644 --- a/packages/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence-sqlite/src/index.ts @@ -7,8 +7,7 @@ * / interrupted-turn-close-on-load semantics the JSONL backend expresses over * file bytes, expressed here over `node:sqlite` rows. Each `SessionEvent` maps * 1:1 onto a row `(session_id, seq, type, time, data)`; `append` is an INSERT - * inside a transaction that asserts the contiguous-seq contract; the mutable - * `SessionSummary` lives in the `sessions` metadata row. + * inside a transaction that asserts the contiguous-seq contract. * * Like the JSONL backend it is also the write-path plugin: it installs the * `session/event` → buffer → `session/flush` drain, persists a fork's seed once @@ -28,7 +27,7 @@ import { SessionPersistence, assertSerializable, seedCoversPrefix, } from '@deepseek-ai/dsh-session-persistence' import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' @@ -47,7 +46,7 @@ export interface Config { /** Backend bookkeeping for a session id (NOT the live Session object). */ interface SessionState { - meta: SessionMeta + meta: SessionHeader /** Next seq to write — equals the number of committed events. */ cursor: number /** Whether the session has at least one persisted event (materialized). */ @@ -108,12 +107,12 @@ export class SessionPersistenceSqlite extends SessionPersistence { // --- SessionPersistence backend surface (all serialized per session id) --- - create(meta: SessionMeta): Promise { - const snapshot: SessionMeta = { ...meta } + create(meta: SessionHeader): Promise { + const snapshot: SessionHeader = { ...meta } return this.serialize(snapshot.id, () => this.createCore(snapshot)) } - private async createCore(meta: SessionMeta): Promise { + private async createCore(meta: SessionHeader): Promise { await this.ready if (this.states.has(meta.id)) { throw new Error(`session "${meta.id}" already exists in this backend`) @@ -173,11 +172,7 @@ export class SessionPersistenceSqlite extends SessionPersistence { for (const event of events) { insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data)) } - // Bump updatedAt on every append (the mutable summary lives in the row). - const updatedAt = Date.now() - this.db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(updatedAt, id) this.db.exec('COMMIT') - state.meta = { ...state.meta, updatedAt } } catch (error) { this.db.exec('ROLLBACK') throw error @@ -186,11 +181,11 @@ export class SessionPersistenceSqlite extends SessionPersistence { state.cursor += events.length } - load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.serialize(id, () => this.loadCore(id)) } - private async loadCore(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { await this.ready const row = this.rowFor(id) if (row === undefined) throw new Error(`session "${id}" not found`) @@ -290,7 +285,7 @@ export class SessionPersistenceSqlite extends SessionPersistence { if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } - async list(): Promise { + async list(): Promise { await this.ready // Every metadata row is a materialized session: the row is written only by // the first append (a created-but-never-appended session has no row), so @@ -320,23 +315,6 @@ export class SessionPersistenceSqlite extends SessionPersistence { this.states.delete(id) } - update(id: SessionId, summary: Partial): Promise { - return this.serialize(id, () => this.updateCore(id, summary)) - } - - private async updateCore(id: SessionId, summary: Partial): Promise { - await this.ready - let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) - const nextMeta: SessionMeta = { ...state.meta, ...summary, updatedAt: summary.updatedAt ?? Date.now() } - // update's only durable effect is the summary fields; the event log is - // untouched. If the row is not materialized yet (a lazy session updated - // before its first append) there is nothing to write — keep the pending - // summary in memory so the materializing append carries it. - if (state.materialized) this.writeRow(nextMeta) - state.meta = nextMeta - } - // --- row helpers --- /** Fetch a session's row, or undefined if absent. */ @@ -346,32 +324,26 @@ export class SessionPersistenceSqlite extends SessionPersistence { } /** - * Insert-or-replace a session's metadata row. The only callers are the first - * materializing `append` and a post-materialization `update`, so writing the - * row IS the materialization (its existence is the signal `has`/`list` read); - * a never-appended session has no row at all. + * Insert-or-replace a session's metadata row. The only caller is the first + * materializing `append`, so writing the row IS the materialization (its + * existence is the signal `has`/`list` read); a never-appended session has no + * row at all. */ - private writeRow(meta: SessionMeta): void { + private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, updated_at, title, first_prompt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, - parent_session = excluded.parent_session, - updated_at = excluded.updated_at, - title = excluded.title, - first_prompt = excluded.first_prompt + parent_session = excluded.parent_session `).run( meta.id, meta.version, meta.createdAt, meta.cwd ?? null, meta.parentSession ?? null, - meta.updatedAt, - meta.title ?? null, - meta.firstPrompt ?? null, ) } @@ -384,7 +356,7 @@ export class SessionPersistenceSqlite extends SessionPersistence { return state } - private assertVersion(meta: SessionMeta): void { + private assertVersion(meta: SessionHeader): void { if (meta.version !== 1) { throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) } @@ -513,7 +485,7 @@ export class SessionPersistenceSqlite extends SessionPersistence { } // case 4: a genuinely new session. - const meta: SessionMeta = { ...session.header, updatedAt: Date.now() } + const meta: SessionHeader = { ...session.header } await this.create(meta) const created = this.states.get(id) /* v8 ignore next -- create() always sets the state for the id */ diff --git a/packages/session-persistence-sqlite/src/schema.ts b/packages/session-persistence-sqlite/src/schema.ts index 247079c3a8..b6e05a0a3f 100644 --- a/packages/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence-sqlite/src/schema.ts @@ -8,18 +8,18 @@ */ import { DatabaseSync } from 'node:sqlite' -import type { SessionEvent, SessionId, SessionMeta } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' /** * The on-disk schema version. Bumped only on a breaking change to the table * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 1 +export const SCHEMA_VERSION = 2 /** - * A row of the `sessions` table — the out-of-log metadata (`SessionMeta`). The - * row's EXISTENCE is the materialization signal: it is written only by the + * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). + * The row's EXISTENCE is the materialization signal: it is written only by the * first `append` (lazy materialization), so a created-but-never-appended * session has no row and is absent from `has`/`list`, mirroring the JSONL * backend's "no file until first append". @@ -30,9 +30,6 @@ export interface SessionRow { created_at: number cwd: string | null parent_session: string | null - updated_at: number - title: string | null - first_prompt: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -51,10 +48,11 @@ export interface EventRow { * * The table-layout version is persisted in SQLite's `PRAGMA user_version` and * checked on open: a fresh database (user_version 0) is stamped with the - * current {@link SCHEMA_VERSION}; an existing database with a NEWER version - * (written by a future, incompatible build) is rejected rather than opened - * against a layout this build does not understand. (An older-but-compatible - * version would be migrated here when migrations exist; v1 has none.) + * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the + * current one (written by a different, incompatible build — older or newer) is + * REJECTED rather than opened against a layout this build does not understand. + * There are no migrations: v1 had a different `sessions` layout and is not + * upgraded in place. */ export function openDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path) @@ -62,9 +60,9 @@ export function openDatabase(path: string): DatabaseSync { db.exec('PRAGMA journal_mode = WAL') // `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 > SCHEMA_VERSION) { + if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { db.close() - throw new Error(`session database at "${path}" has schema version ${onDisk}, newer than this build supports (${SCHEMA_VERSION})`) + throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } if (onDisk === 0) { // Fresh (or pre-versioning) database: stamp the current layout version. @@ -78,10 +76,7 @@ export function openDatabase(path: string): DatabaseSync { version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, - parent_session TEXT, - updated_at INTEGER NOT NULL, - title TEXT, - first_prompt TEXT + parent_session TEXT ) STRICT `) db.exec(` @@ -97,17 +92,14 @@ export function openDatabase(path: string): DatabaseSync { return db } -/** Reconstruct the full {@link SessionMeta} from a `sessions` row. */ -export function rowToMeta(row: SessionRow): SessionMeta { +/** Reconstruct the {@link SessionHeader} from a `sessions` row. */ +export function rowToMeta(row: SessionRow): SessionHeader { return { version: row.version, id: row.id as SessionId, createdAt: row.created_at, - updatedAt: row.updated_at, ...row.cwd !== null ? { cwd: row.cwd } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, - ...row.title !== null ? { title: row.title } : {}, - ...row.first_prompt !== null ? { firstPrompt: row.first_prompt } : {}, } } diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence-sqlite/tests/sqlite.spec.ts index d6216b7b50..6cdc0dcb7a 100644 --- a/packages/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence-sqlite/tests/sqlite.spec.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' @@ -232,14 +232,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b2.dispose() }) - it('rejects opening a database whose schema version is newer than this build', async () => { + 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 // Bump user_version past what this build supports. - const db = openDatabase(path) - db.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`) - db.close() - expect(() => openDatabase(path)).toThrow(/newer than this build/) + const dbNewer = openDatabase(path) + dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`) + dbNewer.close() + expect(() => openDatabase(path)).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) + dbOlder.exec('PRAGMA user_version = 1') + dbOlder.close() + expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/) }) it('append snapshots the batch: mutating an event after the call does not corrupt the persisted copy', async () => { @@ -323,7 +332,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path }) await ctx1.sessionPersistence.create(m) await ctx1.sessionPersistence.append(m.id, oneTurnLog()) - await ctx1.sessionPersistence.update(m.id, { title: 'T', firstPrompt: 'hi' }) await fiber1.dispose() const ctx2 = new Context() @@ -331,7 +339,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path }) expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id) const loaded = await ctx2.sessionPersistence.load(m.id) - expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj', title: 'T', firstPrompt: 'hi' }) + expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' }) expect(loaded.events).toEqual(oneTurnLog()) await fiber2.dispose() }) @@ -340,8 +348,8 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { const path = await freshDbPath() // Materialize a row with version 2 directly via the real schema. const db = openDatabase(path) - db.prepare('INSERT INTO sessions (id, version, created_at, updated_at) VALUES (?, ?, ?, ?)') - .run('v2', 2, 1, 1) + db.prepare('INSERT INTO sessions (id, version, created_at) VALUES (?, ?, ?)') + .run('v2', 2, 1) db.close() const ctx = new Context() @@ -372,7 +380,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(1) + expect(SCHEMA_VERSION).toBe(2) }) }) @@ -468,22 +476,6 @@ describe('SessionPersistenceSqlite: write path (session/event → flush)', () => await expect(ctx2.parallel('session/flush', s2)).rejects.toThrow(/id collision/) await fiber2.dispose() }) - - it('update before the first append keeps the summary in memory and the session lazy', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - const m = meta('lazy-update') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.update(m.id, { title: 'pending' }) - // Still lazy: no materialized row yet. - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) - // The first append materializes and carries the pending title. - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.title).toBe('pending') - await fiber.dispose() - }) }) describe('SessionPersistenceSqlite: edge cases', () => { @@ -529,21 +521,6 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b2.dispose() }) - it('update adopts a session that exists only in the DB (fresh instance)', async () => { - const path = await freshDbPath() - const m = meta('adopt-update') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) - await b1.dispose() - - const b2 = await backend(path) - await b2.ctx.sessionPersistence.update(m.id, { title: 'after restart' }) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - expect(loaded.meta.title).toBe('after restart') - await b2.dispose() - }) - it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') @@ -574,7 +551,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { it('round-trips a header with parentSession (fork lineage)', async () => { const { ctx, dispose } = await backend() - const m: SessionMeta = { ...meta('child'), parentSession: SessionId('parent') } + const m: SessionHeader = { ...meta('child'), parentSession: SessionId('parent') } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 4da1f00df5..521020e80d 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-session-persistence -The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, list, and update sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. +The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. -The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionMeta`, owned by `dsh-session` and re-exported here. +The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. ## Service API (`ctx.sessionPersistence`) @@ -11,9 +11,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | -| `list(): Promise` | Lightweight listing from metadata, no full-log parse. | +| `list(): Promise` | Lightweight listing from metadata, no full-log parse. | | `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | -| `update(id, summary): Promise` | Update mutable `SessionSummary` fields without touching the append-only log. | ## Invariants every backend must honor @@ -30,4 +29,4 @@ Two backends run this suite: `dsh-session-persistence-jsonl` (append-only file l ## Metadata types -Re-exported from `dsh-session`: `SessionHeader` (immutable: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`), `SessionSummary` (mutable: `updatedAt`, `title?`, `firstPrompt?`), `SessionMeta` (their intersection). +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`). diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/src/index.ts index 0ce1e25146..48402b54a7 100644 --- a/packages/session-persistence/src/index.ts +++ b/packages/session-persistence/src/index.ts @@ -1,7 +1,7 @@ /** * The durable session-persistence seam (`ctx.sessionPersistence`): an abstract * service defining WHAT a persistence backend does — durably store, reload, - * list, and update sessions — without saying HOW. Implementations subclass + * and list sessions — without saying HOW. Implementations subclass * {@link SessionPersistence} and register themselves as the * `sessionPersistence` service; `@deepseek-ai/dsh-session-persistence-jsonl` * (an append-only JSONL log per session) is the first and @@ -15,7 +15,7 @@ * parallel "persisted message" type the log must be converted to and from * (faithful to the event-sourced model: the log is the single source of * truth). Metadata that is NOT replayable conversation state (format version, - * cwd, lineage) travels separately as {@link SessionMeta}, which is owned by + * cwd, lineage) travels separately as {@link SessionHeader}, which is owned by * `dsh-session` and re-exported here. * * @module @deepseek-ai/dsh-session-persistence @@ -23,10 +23,10 @@ import { Context, Service } from 'cordis' import { isJsonValue } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' // Re-export the metadata vocabulary so consumers import it from the seam. -export type { SessionHeader, SessionSummary, SessionMeta } from '@deepseek-ai/dsh-session' +export type { SessionHeader } from '@deepseek-ai/dsh-session' declare module 'cordis' { interface Context { @@ -102,7 +102,7 @@ export abstract class SessionPersistence extends Service { * created-but-never-appended session is absent from {@link has}/{@link list} * — abandoned sessions leave nothing behind. */ - abstract create(meta: SessionMeta): Promise + abstract create(meta: SessionHeader): Promise /** * Durably persist a batch of events (called from the write-behind drain at @@ -114,7 +114,7 @@ export abstract class SessionPersistence extends Service { abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** - * Reload a session: its {@link SessionMeta} plus the event log up to the last + * Reload a session: its {@link SessionHeader} plus the event log up to the last * durable checkpoint. Returns `meta` AND `events` so the live session is * reconstructed with its `cwd`/lineage, not just its log. * @@ -135,24 +135,16 @@ export abstract class SessionPersistence extends Service { * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for * the crash-recovery contract. */ - abstract load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> + abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** Lightweight listing from metadata, without a full-log parse. */ - abstract list(): Promise + abstract list(): Promise /** Whether a session is durably present (materialized). */ abstract has(id: SessionId): Promise /** Remove a session and all its persisted artifacts. */ abstract delete(id: SessionId): Promise - - /** - * Update mutable metadata ({@link SessionSummary}: `updatedAt`, `title`, - * `firstPrompt`) WITHOUT touching the append-only event log. A backend - * stores the summary beside the log (a sidecar file, a header row) and - * rewrites only it. - */ - abstract update(id: SessionId, summary: Partial): Promise } export default SessionPersistence diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/tests/contract.ts index 73f6f653bd..704e0abfb0 100644 --- a/packages/session-persistence/tests/contract.ts +++ b/packages/session-persistence/tests/contract.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-session-persistence/tests/contract */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' @@ -20,13 +20,12 @@ export interface ContractBackend { dispose: () => Promise } -/** Build a minimal {@link SessionMeta} for a session id. */ -export function meta(id: string, cwd?: string): SessionMeta { +/** Build a minimal {@link SessionHeader} for a session id. */ +export function meta(id: string, cwd?: string): SessionHeader { return { version: 1, id: SessionId(id), createdAt: 1000, - updatedAt: 1000, ...cwd !== undefined ? { cwd } : {}, } } @@ -242,31 +241,5 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - const m = meta('s7') - const log = oneTurnLog() - await persistence.create(m) - await persistence.append(m.id, log) - const beforeUpdate = (await persistence.load(m.id)).meta.updatedAt - vi.useFakeTimers() - vi.setSystemTime(beforeUpdate + 1_000) - try { - await persistence.update(m.id, { title: 'My session', firstPrompt: 'hi' }) - } finally { - vi.useRealTimers() - } - - const loaded = await persistence.load(m.id) - expect(loaded.meta.title).toBe('My session') - expect(loaded.meta.firstPrompt).toBe('hi') - expect(loaded.meta.updatedAt).toBe(beforeUpdate + 1_000) - expect(loaded.events).toEqual(log) // log untouched - } finally { - await dispose() - } - }) }) } diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/tests/persistence.spec.ts index 5c0a29131f..04ac108ed3 100644 --- a/packages/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/tests/persistence.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SessionId, isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, assertSerializable, seedCoversPrefix } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' @@ -12,10 +12,10 @@ import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' * `@deepseek-ai/dsh-session-persistence-jsonl`. */ class MemoryPersistence extends SessionPersistence { - private store = new Map() - private pending = new Map() + private store = new Map() + private pending = new Map() - async create(m: SessionMeta): Promise { + async create(m: SessionHeader): Promise { // Lazy: record the intended meta, but stay absent from has/list until the // first append materializes the session. this.pending.set(m.id, m) @@ -43,7 +43,7 @@ class MemoryPersistence extends SessionPersistence { } } - async load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> { + async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const entry = this.store.get(id) if (!entry) throw new Error(`session "${id}" not found`) // Honor the crash-recovery contract: if the stored log ends mid-turn, close @@ -54,7 +54,7 @@ class MemoryPersistence extends SessionPersistence { return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } } - async list(): Promise { + async list(): Promise { return [...this.store.values()].map(e => structuredClone(e.meta)) } @@ -66,11 +66,6 @@ class MemoryPersistence extends SessionPersistence { this.store.delete(id) this.pending.delete(id) } - - async update(id: SessionId, summary: Partial): Promise { - const entry = this.store.get(id) - if (entry) Object.assign(entry.meta, summary, { updatedAt: summary.updatedAt ?? Date.now() }) - } } // Run the shared contract against the in-memory backend. diff --git a/packages/session/README.md b/packages/session/README.md index 7cf443fa71..bdd825e2e7 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -31,9 +31,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Metadata types (`types.ts`) -- `SessionHeader` — immutable, written once: `{ version, id, createdAt, cwd?, parentSession? }`. -- `SessionSummary` — mutable, updateable without touching the log: `{ updatedAt, title?, firstPrompt? }`. -- `SessionMeta = SessionHeader & SessionSummary` — owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export these rather than own them (which would force a package cycle). +- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Session event vocabulary (`types.ts`) @@ -45,7 +43,7 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types ### Extension points -- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`/`SessionSummary`/`SessionMeta`, `session.header`) is what such a backend stores beside the log. +- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. - Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. ### What is NOT here (TODO) diff --git a/packages/session/src/types.ts b/packages/session/src/types.ts index 53fca39795..cd1605d93a 100644 --- a/packages/session/src/types.ts +++ b/packages/session/src/types.ts @@ -30,29 +30,6 @@ export interface SessionHeader { parentSession?: SessionId } -/** - * Mutable session metadata — updateable without touching the append-only log. - * A persistence backend stores this beside the log (a sidecar file, a header - * row) and rewrites only it on update. - */ -export interface SessionSummary { - /** Unix epoch milliseconds of the last mutation (event append or update). */ - updatedAt: number - /** Human-facing title (derived/edited), if any. */ - title?: string - /** The first user prompt, cached for listing previews. */ - firstPrompt?: string -} - -/** - * Full session metadata: the immutable {@link SessionHeader} merged with the - * mutable {@link SessionSummary}. Owned here in `dsh-session` (beside - * {@link SessionId}) because `Session.header` is typed by it; the persistence - * package imports/re-exports these rather than owning them, which would force - * a package cycle. - */ -export type SessionMeta = SessionHeader & SessionSummary - /** * Options for creating a {@link Session} via the store. `seed` replays/forks * an existing event log; `meta` carries the caller-supplied storage fields the From 31af23b4fe22d4288067dcf8eba12aa2c93a1c90 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:30:33 +0800 Subject: [PATCH 02/87] docs(session): fix stale sidecar/migration references (Codex review) Codex's converge pass on PR A flagged three now-false references the deletion left behind: - the proposed write-coordinator RFC still listed an "update summary" backend hook and "sidecar behavior" in its test focus; - the JSONL README's format-version note still said a format change needs a "version bump + migration" (contradicting the no-migration pre-release stance); - a stale "sidecar pathing" comment in findLog's cwd-recovery branch. All three corrected to current truth. --- .../2026-06-18-shared-persistence-write-coordinator.md | 4 ++-- packages/session-persistence-jsonl/README.md | 2 +- packages/session-persistence-jsonl/src/index.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md index 06bbe1a93d..fbb7defcd0 100644 --- a/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md @@ -8,7 +8,7 @@ Status: proposed ## Proposal -Extract a backend-agnostic coordinator into `dsh-session-persistence`. The coordinator owns live-session adoption, buffering, cursor filtering, per-id serialization, and disposal quiescence. Concrete backends provide small hooks for durable operations: create lazy state, find/load stored prefix, append a contiguous batch, update summary, delete, and list. +Extract a backend-agnostic coordinator into `dsh-session-persistence`. The coordinator owns live-session adoption, buffering, cursor filtering, per-id serialization, and disposal quiescence. Concrete backends provide small hooks for durable operations: create lazy state, find/load stored prefix, append a contiguous batch, delete, and list. The public `SessionPersistence` service shape can stay the same. The coordinator can be an internal exported helper or protected base class used by first-party backends; third-party backends may still implement the abstract service directly if their write path is different. @@ -16,7 +16,7 @@ The public `SessionPersistence` service shape can stay the same. The coordinator - JSONL and SQLite keep passing the existing shared `runPersistenceContract`. - HMR/adoption/collision tests move to a shared coordinator test suite and run once for each backend through hook-driven fixtures. -- Backend-specific tests focus on storage mechanics only: JSONL path safety/fsync/sidecar behavior and SQLite schema/WAL/transaction behavior. +- Backend-specific tests focus on storage mechanics only: JSONL path safety/fsync behavior and SQLite schema/WAL/transaction behavior. - A future backend does not need to copy the current `session/event` → buffer → flush orchestration. ## Risks diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence-jsonl/README.md index c012886099..640f132e78 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence-jsonl/README.md @@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Format version.** Only v1 is supported; `load` rejects an unknown version. A future format change requires a version bump + migration. +- **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index 110569d3e3..ef921f2b8f 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -490,7 +490,8 @@ export class SessionPersistenceJsonl extends SessionPersistence { for (const dir of await this.listCwdDirs()) { const path = `${dir}/${target}` if (await this.exists(path)) { - // Recover cwd from the header for accurate sidecar pathing. + // Recover the cwd from the header so the caller has the session's + // bucket location (which `findLog` was given an unknown cwd for). const { meta } = scanLog(await readFile(path)) return { path, cwd: meta.cwd } } From ab02e9acecd5411ac77046c8df74fe9c9713f300 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:47:28 +0800 Subject: [PATCH 03/87] refactor(session-persistence): extract a shared write coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the four maps (states/buffers/chains/inits), installWritePath, initFor, onCreated's four adoption cases, flush, drain, serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/ has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows) differed, so every fix landed twice. Extract that orchestration into a PersistenceCoordinator in the seam package. Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a small PersistenceBackend hook interface (loadStored, loadLive, appendBatch, commitRepair, deleteStored, list, optional close), and delegates its six public service methods to it. Composition, not inheritance — a backend exposes only the hooks, can't reach the coordinator's private state, and the public SessionPersistence API is unchanged so a third-party backend may still implement it directly. The crash-repair torn-tail token is OPAQUE: the coordinator computes the synthetic closers (it owns interruptedTurnClosers) but only tests `tornMarker !== undefined` and round-trips it to commitRepair, never inspecting it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not a resume). appendBatch carries meta so lazy-materialize + first-batch commit atomically (no separate materialize hook). Tests: the duplicated orchestration tests (adoption, HMR, collision, dispose-drain, crash-tail) move into one runCoordinatorContract suite run once per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep only storage mechanics. A through-coordinator torn-tail test per real backend keeps the commitRepair-with-marker branch covered under the 100% gate. Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100% coverage; backends shrank ~1200 lines of duplicated churn. Migrates the write-coordinator RFC proposed -> implemented. --- docs/rfc/README.md | 2 +- ...18-shared-persistence-write-coordinator.md | 37 + ...2026-06-19-drop-mutable-session-summary.md | 2 +- ...18-shared-persistence-write-coordinator.md | 24 - .../session-persistence-jsonl/src/index.ts | 836 +++++------------- .../tests/jsonl.spec.ts | 519 +---------- .../session-persistence-sqlite/src/index.ts | 527 +++-------- .../tests/sqlite.spec.ts | 439 +-------- packages/session-persistence/README.md | 24 +- .../session-persistence/src/coordinator.ts | 556 ++++++++++++ packages/session-persistence/src/index.ts | 4 + .../tests/coordinator-contract.ts | 734 +++++++++++++++ .../tests/persistence.spec.ts | 166 +++- 13 files changed, 1879 insertions(+), 1991 deletions(-) create mode 100644 docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md delete mode 100644 docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md create mode 100644 packages/session-persistence/src/coordinator.ts create mode 100644 packages/session-persistence/tests/coordinator-contract.ts diff --git a/docs/rfc/README.md b/docs/rfc/README.md index bc915f2292..3897020434 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -32,7 +32,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Agent lifecycle and ownership seams](proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | -| [Shared persistence write coordinator](proposed/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | ## Implemented @@ -61,6 +60,7 @@ 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/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | +| [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md new file mode 100644 index 0000000000..59ee9bb7c9 --- /dev/null +++ b/docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md @@ -0,0 +1,37 @@ +# RFC: Shared persistence write coordinator + +Status: implemented (proposed and accepted 2026-06-18, implemented 2026-06-20) + +## Problem + +`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load`/`has`/`delete` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed. + +## Decision + +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its six public service methods (`create`/`append`/`load`/`list`/`has`/`delete`) to it. + +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The RFC's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all. + +### The hook interface (`PersistenceBackend`) + +Seven methods (six required + an optional lifecycle hook) — the only seam between the coordinator and storage: + +- `name` — backend label for the dispose-failure `AggregateError`. +- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe and `has`. +- `loadLive(id, cwd)` — read a stored prefix SCOPED to `cwd`. **Deliberately distinct from `loadStored`**: HMR live-adoption must only adopt a persisted log at the SAME cwd as the live session; a same-id log at a different cwd is a collision, not a resume. Collapsing the two reintroduces a cross-cwd adoption bug. SQLite ignores `cwd`. +- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). +- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). +- `deleteStored(id)` / `list()` — remove a stored artifact / list all stored metadata. +- `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. + +### The opaque torn marker + +The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is OPAQUE to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only ever tests `tornMarker !== undefined` and passes the value straight back to `commitRepair` — it never inspects it. Each backend picks its own marker type: JSONL uses the byte offset to truncate to, SQLite the seq to delete from (both happen to be `number`). The JSONL backend folds its `committedBytes < buffer.byteLength` comparison INSIDE the hook so the returned marker is already `number | undefined`; without that fold the coordinator would have to know about byte lengths. + +## Testing + +The shared `runPersistenceContract` (public-API contract) keeps running for every backend. A new `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, dispose-drain, crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). The per-backend specs shrank to storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch. + +## Risks and what we gave up + +The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (seven methods, no inheritance). The hook surface was deliberately held to the minimum: `has` and the create-collision probe are NOT separate hooks — they fold into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements ~7 small primitives instead of copying the entire `session/event` → buffer → flush machinery. diff --git a/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md index f2b82fe2f9..6f73664c26 100644 --- a/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md +++ b/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md @@ -20,7 +20,7 @@ Delete the mutable session summary entirely. `SessionSummary` and the `SessionMe Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it. -This is recorded as a decision because it is **durable** (it narrows a public service contract and an on-disk format across two backends), **contested** (the summary was a deliberate forward-looking design, not an accident), and **surprising** (a future reader finding `SessionHeader` where the original RFC describes `SessionMeta` would otherwise ask why the summary vanished). It also unblocks the [shared persistence write coordinator](../proposed/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. +This is recorded as a decision because it is **durable** (it narrows a public service contract and an on-disk format across two backends), **contested** (the summary was a deliberate forward-looking design, not an accident), and **surprising** (a future reader finding `SessionHeader` where the original RFC describes `SessionMeta` would otherwise ask why the summary vanished). It also unblocks the [shared persistence write coordinator](2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. ## No migration diff --git a/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md deleted file mode 100644 index fbb7defcd0..0000000000 --- a/docs/rfc/proposed/2026-06-18-shared-persistence-write-coordinator.md +++ /dev/null @@ -1,24 +0,0 @@ -# RFC: Shared persistence write coordinator - -Status: proposed - -## Problem - -`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration is now duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards have already moved into the seam package; the remaining orchestration is still correctness-heavy and already receives the same fixes twice. - -## Proposal - -Extract a backend-agnostic coordinator into `dsh-session-persistence`. The coordinator owns live-session adoption, buffering, cursor filtering, per-id serialization, and disposal quiescence. Concrete backends provide small hooks for durable operations: create lazy state, find/load stored prefix, append a contiguous batch, delete, and list. - -The public `SessionPersistence` service shape can stay the same. The coordinator can be an internal exported helper or protected base class used by first-party backends; third-party backends may still implement the abstract service directly if their write path is different. - -## Acceptance Criteria - -- JSONL and SQLite keep passing the existing shared `runPersistenceContract`. -- HMR/adoption/collision tests move to a shared coordinator test suite and run once for each backend through hook-driven fixtures. -- Backend-specific tests focus on storage mechanics only: JSONL path safety/fsync behavior and SQLite schema/WAL/transaction behavior. -- A future backend does not need to copy the current `session/event` → buffer → flush orchestration. - -## Risks - -The current duplication is verbose but explicit. A coordinator must not hide storage-specific durability semantics or make unusual backends fight an inheritance hierarchy. Prefer narrow hooks and contract tests over a large framework. diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index ef921f2b8f..a469095c5d 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -1,20 +1,18 @@ /** * JSONL durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-jsonl`). * - * Two concerns in one plugin: + * One append-only `.jsonl` event log per session (a header line then one + * `SessionEvent` per line, verbatim including `assistant/chunk` so `seq` stays + * contiguous), with lazy materialization (no file until the first `append`), + * atomic first write, and load-time repair of a never-committed crash tail. * - * 1. **The backend** — a concrete {@link SessionPersistence}: one append-only - * `.jsonl` event log per session (a header line then one `SessionEvent` per - * line, verbatim including `assistant/chunk` so `seq` stays contiguous). - * Lazy materialization (no file until the first `append`), atomic first - * write, and load-time repair of a never-committed crash tail. - * - * 2. **The write path** — the `session/event` → buffer → `session/flush` drain - * that generalizes the example `session-jsonl.ts`: snapshot each event when - * it is buffered (the live `session.events` object is mutable), persist - * forks once on `session/created`, maintain a per-session write cursor so a - * resumed session never re-appends already-stored events, and seed existing - * live sessions on plugin apply (HMR does not replay `session/created`). + * The backend supplies ONLY the file-bytes storage primitives (the + * {@link PersistenceBackend} hooks below); all the write-path orchestration + * (the `session/event` → buffer → `session/flush` drain, per-session + * serialization, write cursors, fork-seed persistence, HMR live-adoption, + * crash-repair sequencing, dispose quiescence) lives in the backend-agnostic + * {@link PersistenceCoordinator} this class composes. The six public + * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -25,9 +23,9 @@ import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/prom import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { - SessionPersistence, assertSerializable, seedCoversPrefix, + SessionPersistence, PersistenceCoordinator, + type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, @@ -42,267 +40,152 @@ export interface Config { root: string } -/** Per-session write state held by the backend's in-memory bookkeeping. */ -interface SessionState { - meta: SessionHeader - /** The next seq the backend expects to append (the stored log length). */ - cursor: number - /** Whether the `.jsonl` file has been physically materialized. */ - materialized: boolean - /** - * The live Session this state was bound to via `onCreated`, if any. Used to - * detect a DIFFERENT live session reusing a tracked id (a collision): state - * created through the public `create()`/`load()` API has no owner, but state - * bound to a live session lets `onCreated` reject a second, unrelated session - * object on the same id instead of silently no-opping (which would leave the - * new session's events to be dropped against the old cursor). - */ - owner?: Session -} - /** * Whether `error` is a "no such file/directory" (`ENOENT`) failure — the ONLY * filesystem error that legitimately means "this session/root is absent" for a * durable backend. Any OTHER error (`EACCES`, `ENOTDIR`, transient I/O) must - * surface rather than be silently reported as absence: masking it would let - * `list()` report no sessions, `load()` report "not found", and collision - * checks proceed under a false absence assumption — all unsafe for durable - * persistence. (A NodeJS filesystem rejection carries a string `code`.) + * surface rather than be silently reported as absence. (A NodeJS filesystem + * rejection carries a string `code`.) */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -async function settledErrors(promises: Iterable>): Promise { - const settled = await Promise.allSettled([...promises]) - const errors: unknown[] = [] - for (const result of settled) { - if (result.status === 'rejected') errors.push(result.reason) - } - return errors -} - /** * The JSONL persistence backend. Load as a plugin; it registers as - * `ctx.sessionPersistence` and installs the write-path listeners. + * `ctx.sessionPersistence` and (via the coordinator) installs the write-path + * listeners. Its torn-tail marker is the byte offset to truncate the log to. */ -export class SessionPersistenceJsonl extends SessionPersistence { +export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] static Config: z = z.object({ root: z.string().required(), }) + /** + * Backend label for the coordinator's dispose-failure AggregateError and + * effect name. NOTE: this intentionally shadows cordis `Service.name` (which + * the base sets to `'sessionPersistence'`). The service is registered under the + * fixed key the Service constructor captured (`reflect.provide('sessionPersistence', …)`), + * not via `this.name`, so overwriting the instance field with the backend label + * does not affect `ctx.sessionPersistence` resolution — it only relabels the + * dispose diagnostics, which is exactly what {@link PersistenceBackend.name} is for. + */ + override readonly name = 'session-persistence-jsonl' + private root: string - /** Backend bookkeeping keyed by session id (NOT the live Session object). */ - private states = new Map() - /** Write-behind buffers keyed by the live Session (write path). */ - private buffers = new Map() - /** - * Per-session serialization: every backend operation chains onto the prior - * one for the same id, so concurrent flushes / a flush racing onCreated never - * interleave file writes or read a half-built state. Keyed by session id. - */ - private chains = new Map>() - /** - * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not - * its id: a disposed fiber's session can be replaced by a different live - * Session reusing the same id (HMR, an ACP reconnect), and an id-keyed cache - * would hand the new object the old object's init promise — skipping - * onCreated for the new session, so its events start at seq 0 while flush - * filters against the stale cursor and silently drops them. Keying by object - * gives each live Session its own init. flush awaits it before appending. - */ - private inits = new Map>() + private coordinator: PersistenceCoordinator constructor(ctx: Context, public config: Config) { super(ctx) - // Resolve the configured root to an ABSOLUTE path ONCE, here. A relative - // root (the examples use `./.sessions`) would otherwise re-resolve against - // `process.cwd()` at every later readdir/open — so if any plugin or test - // changed cwd between create, append, and load, one session's files could - // split across directories. Pinning it at construction makes all paths - // stable regardless of later cwd changes. + // Resolve the configured root to an ABSOLUTE path ONCE, here. A relative root + // would otherwise re-resolve against `process.cwd()` at every later + // readdir/open — so if any plugin or test changed cwd between create, append, + // and load, one session's files could split across directories. this.root = resolve(config.root) - this.installWritePath() + this.coordinator = new PersistenceCoordinator(this.ctx, this) } - // --- SessionPersistence backend surface (all serialized per session id) --- + // --- SessionPersistence service surface (delegated to the coordinator) --- create(meta: SessionHeader): Promise { - // Snapshot the metadata at call time: the op runs later (behind the - // per-session chain) and the snapshot is also stored as the lazy state, so - // keeping the caller's object by reference would let a later mutation of - // `id`/`cwd` register under one key but materialize under a different - // path/header. A shallow copy is enough — SessionHeader is a flat record. - const snapshot: SessionHeader = { ...meta } - return this.serialize(snapshot.id, () => this.createCore(snapshot)) + return this.coordinator.create(meta) } - private async createCore(meta: SessionHeader): Promise { - // Do NOT clobber an existing session. If we already track it, or a log - // exists on disk under this id, refuse — the SessionId IS the identity, and - // silently resetting state (cursor 0, materialized false) over committed - // data would let the next append rename over the existing log. - if (this.states.has(meta.id)) { - throw new Error(`session "${meta.id}" already exists in this backend`) - } - // Scan ALL cwd buckets (pass undefined), not just meta.cwd's: load/has/adopt - // identify a session by id alone and search every bucket, so an id already - // persisted under a DIFFERENT cwd must still block creation here. Probing - // only meta.cwd's bucket would let two logs share one id and make resume - // (which picks the first matching bucket) nondeterministic. - if (await this.findLog(meta.id, undefined) !== undefined) { - throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) - } - // Pure lazy: record intent only. No file until the first append, so an - // abandoned (never-appended) session leaves nothing on disk and stays - // absent from has()/list(). - this.states.set(meta.id, { meta, cursor: 0, materialized: false }) - } - - /** - * Run `op` after any in-flight operation for the same session id, so writes - * for one session never interleave (two flushes, a flush racing a load, an - * update racing an append). Errors do not poison the chain — the next op - * still runs. NOTE: serialized public methods must NOT call each other (that - * would deadlock on the same chain); they call the unserialized `*Core` - * helpers instead. - */ - private serialize(id: SessionId, op: () => Promise): Promise { - const prior = this.chains.get(id) ?? Promise.resolve() - const next = prior.then(op, op) - // Keep the chain alive but swallow this op's rejection for the NEXT waiter - // (the caller still sees the real rejection via `next`). - this.chains.set(id, next.then(() => undefined, () => undefined)) - return next - } - - // `async` so the synchronous validate/clone below reject (not throw) per the - // Promise contract — callers use `await expect(...).rejects`. - async append(id: SessionId, events: readonly SessionEvent[]): Promise { - // Validate serializability BEFORE cloning, so a bad event surfaces the typed - // "non-JSON-serializable" error rather than an opaque DataCloneError from - // structuredClone below. (In an async method this throw becomes a rejection, - // honoring the Promise contract rather than throwing synchronously.) - assertSerializable(events) - // Deep-snapshot the batch here, BEFORE the op waits behind the per-session - // chain: the op may await before serializing, so a caller that passes a live - // array (e.g. session.events) and mutates it — OR mutates an event object - // inside it — before the op runs would otherwise have those changes - // persisted, or advance the cursor past what was actually written. - // structuredClone covers both the array and the event objects (safe now that - // serializability is checked above). The clone happens synchronously (before - // the first await), so it is taken at call time. - const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this.appendCore(id, batch)) - } - - private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { - if (events.length === 0) return - assertSerializable(events) - let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls loadCore, not load - - // Contiguity contract: each event's seq must continue the stored log. - for (const [i, event] of events.entries()) { - if (event.seq !== state.cursor + i) { - throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`) - } - } - - if (!state.materialized) { - await this.materialize(state, events) - } else { - await this.appendLines(state, events) - } - // The durable event log is the transaction: advance the cursor as soon as - // the log write commits. - state.cursor += events.length + append(id: SessionId, events: readonly SessionEvent[]): Promise { + return this.coordinator.append(id, events) } load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this.loadCore(id)) + return this.coordinator.load(id) } - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const cwd = this.states.get(id)?.meta.cwd + has(id: SessionId): Promise { + return this.coordinator.has(id) + } + + delete(id: SessionId): Promise { + return this.coordinator.delete(id) + } + + // `list` is BOTH the public service method and the PersistenceBackend hook — + // one method, the bucket walk below. The coordinator adds no orchestration for + // listing (no per-id serialization, no cursor), so it would just call back into + // this same method; routing it through the coordinator would recurse. Defined + // once, in the "PersistenceBackend hooks" section. + + /** + * The per-session init promises, exposed for white-box tests that await a + * specific session's onCreated (there is no public API to await one init). + */ + get inits(): Map> { + return this.coordinator.inits + } + + // --- PersistenceBackend hooks (the file-bytes storage primitives) --- + + /** Read a stored prefix by id across ALL cwd buckets (cwd unknown). */ + async loadStored(id: SessionId): Promise | undefined> { + return this.readPrefix(id, undefined) + } + + /** Read a stored prefix SCOPED to `cwd` (HMR live-adoption must not cross cwd). */ + async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { + return this.readPrefix(id, cwd) + } + + /** + * Read and scan a session's log into a {@link StoredPrefix}. Folds the + * torn-tail comparison HERE so the `tornMarker` is the byte offset to truncate + * to (or `undefined` when nothing is torn) — the coordinator never sees the + * raw byteLength. + */ + private async readPrefix(id: SessionId, cwd: string | undefined): Promise | undefined> { const file = await this.findLog(id, cwd) - if (file === undefined) throw new Error(`session "${id}" not found`) + if (file === undefined) return undefined const buffer = await readFile(file.path) const { meta, events, committedBytes } = scanLog(buffer) - this.assertVersion(meta) - - // Crash-recovery: if the log ended mid-turn (an open turn with real, - // preserved events but no closing turn/end), close it durably DURING load so - // disk, the returned log, and the cursor all agree — both append routes then - // continue with no special-casing. Synthesize the boundary events (a - // step/end if a step was open, then a turn/end {kind:'interrupted'}); the - // interrupted turn's real events are preserved, never truncated (a turn can - // be huge — the session-persistence RFC). - const closers = interruptedTurnClosers(events) - const balanced = [...events, ...closers] - - // Set state BEFORE the repair writes so they can resolve the log path. - const needsTorn = committedBytes < buffer.byteLength - const state: SessionState = { - meta: { ...meta }, - cursor: events.length, - materialized: true, + return { + meta, + events, + ...committedBytes < buffer.byteLength ? { tornMarker: committedBytes } : {}, } - this.states.set(id, state) - - if (needsTorn) { - // Discard the torn trailing fragment (a final line never fully flushed) - // before writing the closers, so the closers land at a clean EOF. - await this.repair(state, committedBytes) - } - if (closers.length > 0) { - // Durably append the synthetic closers, then advance the cursor to the - // balanced length. After this, disk == balanced and the next append (live - // or direct) continues cleanly. - await this.appendLines(state, closers) - state.cursor = balanced.length - } - - return { meta, events: balanced } } - private async adoptLiveDiskPrefix( - session: Session, - seed: readonly SessionEvent[], - file: { path: string; cwd: string | undefined }, - ): Promise { - const buffer = await readFile(file.path) - const { meta, events, committedBytes } = scanLog(buffer) - this.assertVersion(meta) - if (!seedCoversPrefix(seed, events)) { - throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) + /** Durably append a batch, lazily materializing the file when not yet present. */ + async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { + if (isMaterialized) { + await this.appendLines(meta, events) + } else { + await this.materialize(meta, events) } - - const state: SessionState = { - meta: { ...meta }, - cursor: events.length, - materialized: true, - owner: session, - } - this.states.set(session.header.id, state) - - if (committedBytes < buffer.byteLength) { - await this.repair(state, committedBytes) - } - const suffix = seed.slice(events.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } + /** + * Make a crash repair durable: truncate the torn tail to `tornMarker` bytes (if + * any), then append the synthetic `closers` (if any). Two fsync'd steps — the + * seam does not require this to be atomic. + */ + async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise { + if (tornMarker !== undefined) await this.repair(meta, tornMarker) + if (closers.length > 0) await this.appendLines(meta, closers) + } + + /** Remove a session's log file (the coordinator clears its in-memory state). */ + async deleteStored(id: SessionId): Promise { + const file = await this.findLog(id, undefined) + if (file) await rm(file.path, { force: true }) + } + + /** List all stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { const metas: SessionHeader[] = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { // Read ONLY the header line, not the whole log: a session picker must // scale with the number of sessions, not the total size of every - // conversation (the log persists every assistant/chunk verbatim, so a - // full scanLog here would be O(total history)). + // conversation (the log persists every assistant/chunk verbatim). const first = await this.readFirstLine(`${dir}/${name}`) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) @@ -313,11 +196,119 @@ export class SessionPersistenceJsonl extends SessionPersistence { return metas } + // --- materialization / append / repair (file mechanics) --- + + /** Atomically write the header line + first batch (temp-write, fsync, rename). */ + private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise { + const dir = sessionDir(this.root, meta.cwd) + await mkdir(this.root, { recursive: true, mode: 0o700 }) + await this.syncDir(dirname(this.root)) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await this.syncDir(this.root) + const finalPath = logPath(this.root, meta.cwd, meta.id) + // Never rename over an existing committed log: materialize is the FIRST write + // of a session the backend believes is new. A file here means a different + // session shares this id on disk — reject loudly. (createCore already guards + // the create path, so this is unreachable-in-practice TOCTOU defense.) + /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ + if (await this.exists(finalPath)) { + throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`) + } + const header = JSON.stringify(toHeaderLine(meta)) + const body = events.map(eventLine).join('\n') + const content = header + '\n' + body + '\n' + + const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp` + const handle = await open(tmp, 'wx', 0o600) + try { + await handle.writeFile(content) + await handle.sync() + } finally { + await handle.close() + } + // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the + // final path already exists, so two processes materializing the same id + // concurrently cannot clobber each other. rename() would silently overwrite. + let linked = false + try { + await link(tmp, finalPath) + linked = true + } finally { + // If link FAILED, the temp is the only reference and must be removed before + // the original error propagates. If it SUCCEEDED, defer temp cleanup to + // AFTER the publish is durable (below) so a temp-rm failure can never reject + // a session whose log already published. + /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ + if (!linked) await rm(tmp, { force: true }) + } + // link() succeeded — the log is published. fsync the directory so the new + // entry survives a power loss: the new link is not crash-durable until the + // parent directory's metadata is synced. + await this.syncDir(dir) + // Best-effort temp cleanup: the log is already published and durable, so a + // failure to remove the (now-redundant) temp hard link must NOT reject the + // append. Swallow only the rm failure; nothing else of consequence runs here. + try { + await rm(tmp, { force: true }) + } catch { + /* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */ + } + } + + /** fsync a directory so a just-created/renamed entry inside it is crash-durable. */ + private async syncDir(dir: string): Promise { + const handle = await open(dir, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } + } + + /** + * Append event lines at EOF and fsync. On a write/sync failure AFTER the kernel + * accepted some bytes (ENOSPC, an fsync error), truncate the file back to its + * pre-append size before rethrowing: the cursor is unchanged, so the batch will + * be retried, and without this rollback the retry would append AFTER the partial + * bytes — producing duplicate seqs that make `scanLog` see a gap. + */ + private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise { + const path = logPath(this.root, meta.cwd, meta.id) + const handle = await open(path, 'a') + try { + const { size: before } = await handle.stat() + try { + await handle.writeFile(events.map(eventLine).join('\n') + '\n') + await handle.sync() + } catch (error) { + // Roll back whatever bytes landed so a retry starts from a clean EOF. + await handle.truncate(before) + await handle.sync() + throw error + } + } finally { + await handle.close() + } + } + + /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */ + private async repair(meta: SessionHeader, offset: number): Promise { + const path = logPath(this.root, meta.cwd, meta.id) + await truncate(path, offset) + const handle = await open(path, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + } + + // --- discovery helpers --- + /** * Read the first newline-terminated line of a file without loading the whole - * file. Returns undefined if the file is empty or has no complete first line - * (a half-written log). Reads in bounded chunks so a huge log costs only the - * header read. + * file. Returns undefined if the file is empty or has no complete first line. + * Reads in bounded chunks so a huge log costs only the header read. */ private async readFirstLine(path: string): Promise { const handle = await open(path, 'r') @@ -340,145 +331,6 @@ export class SessionPersistenceJsonl extends SessionPersistence { } } - async has(id: SessionId): Promise { - const state = this.states.get(id) - if (state?.materialized) return true - const cwd = state?.meta.cwd - return (await this.findLog(id, cwd)) !== undefined - } - - delete(id: SessionId): Promise { - return this.serialize(id, () => this.deleteCore(id)) - } - - private async deleteCore(id: SessionId): Promise { - const cwd = this.states.get(id)?.meta.cwd - const file = await this.findLog(id, cwd) - if (file) await rm(file.path, { force: true }) - this.states.delete(id) - } - - // --- materialization / append / repair --- - - /** Atomically write the header line + first batch (temp-write, fsync, rename). */ - private async materialize(state: SessionState, events: readonly SessionEvent[]): Promise { - const dir = sessionDir(this.root, state.meta.cwd) - await mkdir(this.root, { recursive: true, mode: 0o700 }) - await this.syncDir(dirname(this.root)) - await mkdir(dir, { recursive: true, mode: 0o700 }) - await this.syncDir(this.root) - const finalPath = logPath(this.root, state.meta.cwd, state.meta.id) - // Never rename over an existing committed log: materialize is the FIRST - // write of a session the backend believes is new. A file here means a - // different session shares this id on disk — reject loudly rather than - // clobber committed data. (createCore already guards the create path before - // this point, so this is unreachable-in-practice defense-in-depth against a - // TOCTOU/fork race; ignored for coverage.) - /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ - if (await this.exists(finalPath)) { - throw new Error(`refusing to materialize "${state.meta.id}": a log already exists on disk (load/resume it instead)`) - } - const header = JSON.stringify(toHeaderLine(state.meta)) - const body = events.map(eventLine).join('\n') - const content = header + '\n' + body + '\n' - - const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp` - const handle = await open(tmp, 'wx', 0o600) - try { - await handle.writeFile(content) - await handle.sync() - } finally { - await handle.close() - } - // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the - // final path already exists, so two processes materializing the same id - // concurrently cannot clobber each other (both could pass the exists() check - // above, but only one link() wins). rename() would silently overwrite the - // log the other process just committed. - let linked = false - try { - await link(tmp, finalPath) - linked = true - } finally { - // If link FAILED (EEXIST on a race, or any I/O error), the temp is the - // only reference and must be removed before the original error propagates. - // If link SUCCEEDED, the temp cleanup is deferred to AFTER the publish is - // durable (below) so a temp-rm failure can never reject a session whose - // log already published — that would leave state.materialized false and - // wedge every retry on the exists() backstop above. - /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ - if (!linked) await rm(tmp, { force: true }) - } - // link() succeeded — the log is published. fsync the directory so the new - // entry survives a power loss: on POSIX filesystems the new link is not - // crash-durable until the parent directory's metadata is synced. The seam - // contract is "append returns once durable", and materialize is the first - // append's write — so the directory entry must be durable before we return. - await this.syncDir(dir) - state.materialized = true - // Best-effort temp cleanup: the log is already published and durable, so a - // failure to remove the (now-redundant) temp hard link must NOT reject the - // append. A leftover `*.tmp` is harmless — it is never read, and the next - // materialize of this id is guarded by exists()/link(). Swallow only the - // rm failure; nothing else of consequence runs in the try. - try { - await rm(tmp, { force: true }) - } catch { - /* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */ - } - } - - /** fsync a directory so a just-created/renamed entry inside it is crash-durable. */ - private async syncDir(dir: string): Promise { - const handle = await open(dir, 'r') - try { - await handle.sync() - } finally { - await handle.close() - } - } - - /** - * Append event lines at EOF and fsync. On a write/sync failure AFTER the - * kernel accepted some bytes (ENOSPC, an fsync error), truncate the file back - * to its pre-append size before rethrowing: `cursor` is unchanged, so the - * batch will be retried, and without this rollback the retry would append - * AFTER the partial bytes — producing duplicate seqs that make `scanLog` see a - * gap and render the session unloadable. - */ - private async appendLines(state: SessionState, events: readonly SessionEvent[]): Promise { - const path = logPath(this.root, state.meta.cwd, state.meta.id) - const handle = await open(path, 'a') - try { - const { size: before } = await handle.stat() - try { - await handle.writeFile(events.map(eventLine).join('\n') + '\n') - await handle.sync() - } catch (error) { - // Roll back whatever bytes landed so a retry starts from a clean EOF. - await handle.truncate(before) - await handle.sync() - throw error - } - } finally { - await handle.close() - } - } - - /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */ - private async repair(state: SessionState, offset: number): Promise { - const path = logPath(this.root, state.meta.cwd, state.meta.id) - await truncate(path, offset) - const handle = await open(path, 'r+') - try { - await handle.sync() - } finally { - await handle.close() - } - } - - // --- discovery helpers --- - /** Find a session's log file across cwd buckets (when cwd is unknown). */ private async findLog(id: SessionId, cwd: string | undefined): Promise<{ path: string; cwd: string | undefined } | undefined> { if (cwd !== undefined) { @@ -490,8 +342,7 @@ export class SessionPersistenceJsonl extends SessionPersistence { for (const dir of await this.listCwdDirs()) { const path = `${dir}/${target}` if (await this.exists(path)) { - // Recover the cwd from the header so the caller has the session's - // bucket location (which `findLog` was given an unknown cwd for). + // Recover the cwd from the header so the caller has the session's bucket. const { meta } = scanLog(await readFile(path)) return { path, cwd: meta.cwd } } @@ -505,10 +356,9 @@ export class SessionPersistenceJsonl extends SessionPersistence { const entries = await readdir(this.root, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`) } catch (error) { - // ENOENT = the root has not been created yet → genuinely no sessions. - // Any other error (EACCES, ENOTDIR, transient I/O) must NOT be reported - // as "no sessions" — a durable backend cannot silently pretend persisted - // state is absent on a storage fault. + // ENOENT = the root has not been created yet → genuinely no sessions. Any + // other error (EACCES, ENOTDIR, transient I/O) must NOT be reported as "no + // sessions" — a durable backend cannot silently pretend state is absent. if (isENOENT(error)) return [] throw error } @@ -526,244 +376,12 @@ export class SessionPersistenceJsonl extends SessionPersistence { return true } catch (error) { // Only ENOENT means absent. A permission/I/O error must surface, not be - // collapsed to `false` — otherwise load() reports "not found" and - // collision checks proceed under a false absence assumption. + // collapsed to `false` — otherwise load() reports "not found" and collision + // checks proceed under a false absence assumption. if (isENOENT(error)) return false throw error } } - - /** Build a state for a session discovered on disk but not yet in memory. */ - private async adopt(id: SessionId): Promise { - // loadCore (NOT load) — adopt runs inside an already-serialized op, so - // re-entering the chain via the public load() would deadlock. - await this.loadCore(id) - const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ - if (!state) throw new Error(`failed to adopt session "${id}"`) - return state - } - - private assertVersion(meta: SessionHeader): void { - if (meta.version !== 1) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) - } - } - - // --- write path (session/event → flush drain) --- - - private installWritePath(): void { - const ctx = this.ctx - - // Capture the header on creation; persist a fork's seed once. Record the - // init promise so flush/dispose can await it (onCreated is async). - ctx.on('session/created', (session) => { void this.initFor(session) }) - - // Snapshot + buffer every event (the live object is mutable; clone so a - // later in-place mutation of session.events cannot rewrite a buffered - // event). Serializability is guaranteed at the source — `Session.append` - // rejects non-JSON-serializable data before the event ever enters the log - // or this emit — so structuredClone here can never hit a non-cloneable - // value, and the durable log can never diverge from session.events. - ctx.on('session/event', (session, event) => { - let buffer = this.buffers.get(session) - if (!buffer) this.buffers.set(session, buffer = []) - buffer.push(structuredClone(event)) - }) - - // Drain to the backend at the durability checkpoint. - ctx.on('session/flush', session => this.flush(session)) - - // Dispose must reach quiescence: await every session's init + final drain - // BEFORE returning, so no write lands after teardown (orphan rename/ENOENT). - ctx.effect(() => async () => { - const errors = [ - ...await settledErrors(this.inits.values()), - ...await settledErrors([...this.buffers.keys()].map(s => this.flush(s))), - ...await settledErrors(this.chains.values()), - ] - if (errors.length > 0) { - throw new AggregateError(errors, 'session-persistence-jsonl dispose failed') - } - }, 'session-persistence-jsonl write path') - - // HMR: a hot reload does not replay session/created, so seed existing live - // sessions (mirrors dsh-invariants). - for (const session of ctx.sessions.list()) void this.initFor(session) - } - - /** Start (once) the async init for a session and remember its promise. */ - private initFor(session: Session): Promise { - const existing = this.inits.get(session) - if (existing) return existing - // Snapshot the seed SYNCHRONOUSLY here — initFor runs inside the - // `session/created` emit, before any later `append` adds non-seed events. - // A clone freezes it against later mutation of the live event objects. - const seed = session.events.map(e => structuredClone(e)) - const p = this.onCreated(session, seed) - // Attach a no-op rejection handler so a failing init (e.g. an id collision) - // does not surface as an unhandled rejection if no flush observes `p` before - // it rejects. The REAL error is still delivered: flush/dispose await the - // same `p` from the map and see the rejection there. - p.catch(() => { /* observed by flush/dispose via the stored promise */ }) - this.inits.set(session, p) - return p - } - - /** - * Whether a live `session`'s `seed` reproduces the first `cursor` persisted - * events. Reads the on-disk committed prefix and compares. A `cursor` of 0 - * (nothing persisted yet) trivially matches. Used when a live session claims - * ownerless state left by a prior `load()`/`create()` — to reject a fresh, - * unrelated session that reuses the id and would otherwise have its seq - * 0..cursor-1 events filtered as already-written. - */ - private async seedMatchesPersisted(session: Session, seed: readonly SessionEvent[], cursor: number): Promise { - if (cursor === 0) return true - const onDisk = await this.findLog(session.header.id, session.header.cwd) - /* v8 ignore next -- a cursor > 0 means the log was materialized, so it exists */ - if (onDisk === undefined) return false - const { events: diskEvents } = scanLog(await readFile(onDisk.path)) - return seedCoversPrefix(seed, diskEvents.slice(0, cursor)) - } - - /** - * On session/created: sync the backend's in-memory state to a live Session. - * - * Cases, by whether this backend tracks the id and whether a log is on disk: - * 1. Already in `states` (created here, or a prior load/resume) → no-op. - * 2. Not tracked, a log EXISTS on disk, and it is a seq-aligned PREFIX of the - * live session's current events → ADOPT it (HMR/reload): a fresh backend - * instance (empty `states`) meets a live session whose log a previous - * instance materialized; the live object already carries that history (it - * is the source of truth this run), so we continue from the stored length - * instead of re-creating. This keeps persistence alive across hot reload. - * 3. Not tracked, a log EXISTS on disk, but it is NOT a prefix of the live - * session's events → REJECT: a different session collides on the id. The - * SessionId is the identity, so two unrelated sessions sharing one is a - * bug, not a resume — fail loudly rather than clobber committed data. - * 4. Not tracked and NO log on disk → a genuinely new session: register its - * meta (lazy) and persist its `seed` once. - * - * The public `create(meta)` API is stricter still (rejects ANY on-disk id): - * there the caller asserts "brand new", so even a prefix match is a bug. - * - * The seed events were copied into the Session by its constructor WITHOUT - * emitting session/event, so the write-behind buffer never sees them — the - * one explicit `append(seed)` below is the only persistence of the seed. - * Events appended AFTER creation flow through the session/event buffer and - * are persisted by flush (filtered by the write cursor), never here. - */ - private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise { - const id = session.header.id - const tracked = this.states.get(id) - if (tracked !== undefined) { - // case 1: already tracked. - // (owner === session is a defensive same-object guard: initFor dedupes by - // session object, so onCreated never actually runs twice for one session.) - /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */ - if (tracked.owner === session) return - if (tracked.owner === undefined) { - // Ownerless state was created via the public create()/load() API. The - // FIRST live session to arrive claims it — but ONLY if its seed is the - // already-persisted prefix. A load() for preview leaves cursor at the - // persisted length; a fresh, unrelated session reusing that id has a - // seed shorter than (or not matching) that prefix, so flush would filter - // its seq 0..cursor-1 events as already-written and silently graft the - // new conversation onto the old log. Verify the seed covers the cursor. - if (!await this.seedMatchesPersisted(session, seed, tracked.cursor)) { - throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) - } - tracked.owner = session - // Persist the live seed SUFFIX beyond the persisted prefix. Constructor - // seed events (from sessions.create(id, { seed })) never emit - // session/event, so the write-behind buffer never sees them — without - // this they would be lost and a later flush would seq-mismatch. (cursor - // is 0 for a public create(), so this covers the whole seed there.) - const suffix = seed.slice(tracked.cursor) - if (suffix.length > 0) await this.append(id, suffix) - return - } - // The state is owned by a DIFFERENT live session. We may reclaim the id - // ONLY if that owner left nothing behind: never materialized a log (cursor - // 0, not materialized) AND has no write-behind buffer still pending. A - // session that appended events but was disposed before its first flush is - // NOT materialized yet but DOES have buffered events — reclaiming then - // would let that stale buffer drain against the new session's state - // (persisting old events under the new id, or dropping the new session's - // seq-0 events). Such an owner, and any materialized owner, is a real - // collision and rejects; only a truly-abandoned (artifact-free) id is - // freed, honoring lazy materialization's "leaves nothing behind" promise. - const ownerBuffer = this.buffers.get(tracked.owner) - if (!tracked.materialized && !ownerBuffer?.length) { - this.states.delete(id) - } else { - throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) - } - } - - const onDisk = await this.findLog(id, session.header.cwd) - if (onDisk !== undefined) { - // case 2: adopt a LIVE prefix. Do NOT route through loadCore(): loadCore - // crash-repairs open turns as interrupted, which is right for a true load - // after a crash but wrong for HMR while the live Session is still the - // authority and may append the real step/turn end later. - await this.serialize(id, () => this.adoptLiveDiskPrefix(session, seed, onDisk)) - return - } - - // case 4: a genuinely new session. Register its meta (lazy), then persist - // its seed (events present at creation time) once. - const meta: SessionHeader = { ...session.header } - await this.create(meta) - // Bind this state to the live session so a later DIFFERENT session reusing - // the id is detected as a collision (case 1) rather than silently no-opped. - const created = this.states.get(id) - /* v8 ignore next -- create() always sets the state for the id */ - if (created !== undefined) created.owner = session - if (seed.length > 0) { - await this.append(id, seed) - } - } - - private async flush(session: Session): Promise { - // Wait for the session's init (onCreated) to finish so the state/cursor and - // any fork-seed persistence are in place before we drain. Awaiting the same - // promise initFor stored also surfaces an init failure (e.g. an id - // collision) here, where the caller of session/flush observes it. - await this.inits.get(session) - // Serialize the WHOLE drain (read cursor → append → splice) on the - // per-session chain. Two concurrent flushes (e.g. an idle inject()'s - // fire-and-forget flush racing an explicit checkpoint) would otherwise both - // read the same cursor, both compute the same `fresh` slice, and the second - // append would seq-mismatch against the cursor the first already advanced. - await this.serialize(session.header.id, () => this.drain(session)) - } - - /** Drain a session's write buffer to disk. Caller serializes this per id. */ - private async drain(session: Session): Promise { - const buffer = this.buffers.get(session) - if (!buffer?.length) return - // Copy WITHOUT removing: the buffer is the only durable-pending copy of - // these events (session/event does not re-emit). Splicing before the append - // means a failed append (disk error, or a seq mismatch after a dropped bad - // event) permanently loses a completed turn. Drain the buffer only AFTER - // the append commits; events pushed during the await sit past batch.length - // and survive the prefix splice, so a retry/dispose re-drains the rest. - const batch = buffer.slice() - const state = this.states.get(session.header.id) - // Only append events at or beyond the write cursor (a resumed session's - // seed is already on disk; the cursor was set to the loaded length). flush - // awaits the init above, which always sets state, so the `?? 0` fallback is - // a defensive guard that never fires in practice. - /* v8 ignore next -- state is always set by the awaited init before flush */ - const cursor = state?.cursor ?? 0 - const fresh = batch.filter(e => e.seq >= cursor) - // appendCore (NOT the serialized append) — drain already runs inside the - // per-session chain, so re-entering it via append() would deadlock. - if (fresh.length > 0) await this.appendCore(session.header.id, fresh) - buffer.splice(0, batch.length) - } } export default SessionPersistenceJsonl diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence-jsonl/tests/jsonl.spec.ts index 07cc470c6e..b48bb249d1 100644 --- a/packages/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence-jsonl/tests/jsonl.spec.ts @@ -4,10 +4,11 @@ import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } fr import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] @@ -37,6 +38,29 @@ runPersistenceContract('jsonl', async () => { } }) +// Run the shared coordinator orchestration suite against the real JSONL backend. +// One temp root is the shared storage scope (two mounted instances over the same +// root = HMR/reload). `corruptTail` appends a partial, newline-less fragment to +// the session's .jsonl past the committed region — a never-committed torn tail +// that drives the coordinator's commitRepair-with-tornMarker branch over real +// file bytes. +runCoordinatorContract('jsonl', async (): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) + return { + mount: async (ctx) => { + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir }) + return fiber + }, + corruptTail: async (id, cwd) => { + // A half-written record with no trailing newline: scanLog treats it as an + // uncommitted crash fragment and reports committedBytes < byteLength, so + // the coordinator sees a tornMarker to truncate. + await appendFile(logPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti') + }, + cleanup: async () => { await rm(dir, { recursive: true, force: true }) }, + } +}) + describe('SessionPersistenceJsonl: format helpers', () => { it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { expect(encodeSegment('..')).toBe('~002E~002E') @@ -198,36 +222,6 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) - it('append snapshots its batch: mutating the caller array after the call is ignored', async () => { - const m = meta('snapshot') - await ctx.sessionPersistence.create(m) - const events = oneTurnLog() // seqs 0..5 - const p = ctx.sessionPersistence.append(m.id, events) - // Mutate the caller's array immediately after calling append (before the - // queued op runs). The backend must persist the snapshot taken at call time, - // not the mutated array. - events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }) - await p - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6 - }) - - it('append deep-snapshots event objects: mutating an event after the call is ignored', async () => { - const m = meta('deep-snapshot') - await ctx.sessionPersistence.create(m) - const events = oneTurnLog() - const userMsg = events[1] // the user/message event - const p = ctx.sessionPersistence.append(m.id, events) - // Mutate an event OBJECT (not just the array) after calling append. The deep - // snapshot taken at call time must shield the persisted data. - if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'MUTATED' }] - await p - const loaded = await ctx.sessionPersistence.load(m.id) - const persisted = JSON.stringify(loaded.events) - expect(persisted).toContain('hi') // original content - expect(persisted).not.toContain('MUTATED') - }) - it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { const m = meta('meta-copy', '/proj') await ctx.sessionPersistence.create(m) @@ -246,25 +240,6 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) - it('rejects an unknown format version on load', async () => { - const m = meta('v2') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - // Corrupt the header version on disk. - const path = logPath(root, undefined, m.id) - const lines = (await readFile(path, 'utf8')).split('\n') - const header = JSON.parse(lines[0]!) as { version: number } - header.version = 2 - lines[0] = JSON.stringify(header) - await writeFile(path, lines.join('\n')) - // Fresh backend (no in-memory state) → must reject on load. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - await expect(ctx2.sessionPersistence.load(m.id)).rejects.toThrow(/version/) - await ctx2.fiber.dispose() - }) - it('rejects a re-append of an already-stored seq', async () => { const m = meta('reappend') await ctx.sessionPersistence.create(m) @@ -293,62 +268,6 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { }) describe('SessionPersistenceJsonl: write path (session/event → flush)', () => { - it('persists a live session driven through the store, surviving reload', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) - - const session = ctx.sessions.create('live', { meta: { cwd: '/w' } }) - for (const e of oneTurnLog()) session.append(e.type, e.data) - await ctx.parallel('session/flush', session) - - const loaded = await ctx.sessionPersistence.load(SessionId('live')) - expect(loaded.events).toHaveLength(6) - expect(loaded.meta.cwd).toBe('/w') - await ctx.fiber.dispose() - }) - - it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) - - const session = ctx.sessions.create('mutate') - const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) - // Mutate the live event object AFTER it was buffered. - ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - const loaded = await ctx.sessionPersistence.load(SessionId('mutate')) - const first = loaded.events[0] - expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original') - await ctx.fiber.dispose() - }) - - it('fork: a seeded new session persists its seed once', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) - - const seed = oneTurnLog() - // A fork: a brand-new id whose seed came from elsewhere. - const forked = ctx.sessions.create('forked', { seed }) - // onCreated persisted the seed asynchronously; wait a tick. - await new Promise(r => setTimeout(r, 10)) - const loaded = await ctx.sessionPersistence.load(SessionId('forked')) - expect(loaded.events).toEqual(seed) - // A flush with no NEW events must not double-write. - await ctx.parallel('session/flush', forked) - const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) - expect(reloaded.events).toEqual(seed) - await ctx.fiber.dispose() - }) - it('concurrent sessions do not cross buffers', async () => { root = await freshRoot() const ctx = new Context() @@ -373,112 +292,6 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => await ctx.fiber.dispose() }) - it('HMR: applying the plugin seeds existing live sessions', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - // A session exists BEFORE the persistence plugin is applied. - const session = ctx.sessions.create('pre-existing') - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - await ctx.plugin(SessionPersistenceJsonl, { root }) - // The plugin seeded it on apply; a subsequent flush persists its events. - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing')) - expect(loaded.events.length).toBeGreaterThanOrEqual(2) - await ctx.fiber.dispose() - }) - - it('HMR: dispose drains remaining buffers', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - let session!: Session - const fiber = await ctx.plugin(SessionPersistenceJsonl, { root }) - const sessFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('drain') - }, { inject: ['sessions'] })) - session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // No explicit flush — dispose must drain. - await fiber.dispose() - await sessFiber.dispose() - - // A fresh backend reads what the disposed one drained. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - const loaded = await ctx2.sessionPersistence.load(SessionId('drain')) - expect(loaded.events.length).toBeGreaterThanOrEqual(2) - await ctx2.fiber.dispose() - }) - - it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - // The session lives in its OWN fiber so it survives the backend reload. - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-adopt') - }, { inject: ['sessions'] })) - - // Backend instance 1 materializes the session on disk. - const backend1 = await ctx.plugin(SessionPersistenceJsonl, { root }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - // Hot-reload the backend: dispose instance 1, plug in instance 2 over the - // SAME root while the session stays live. Instance 2 has an empty states - // map but the log is on disk — it must ADOPT (not reject) so flush keeps - // working. A second turn appended after reload then persists. - await backend1.dispose() - await ctx.plugin(SessionPersistenceJsonl, { root }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() - - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - await ctx.fiber.dispose() - }) - - it('HMR: adoption persists the live SUFFIX that was ahead of the on-disk prefix', async () => { - root = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-suffix') - }, { inject: ['sessions'] })) - - // Instance 1 flushes turn 1 to disk. - const backend1 = await ctx.plugin(SessionPersistenceJsonl, { root }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT - // flushing turn 2. Turn 2 is now ONLY in the live session's events; the new - // backend never buffered it via session/event. - await backend1.dispose() - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - - // Instance 2 adopts the on-disk prefix (turn 1) and MUST also persist the - // live suffix (turn 2) carried in the session's events — otherwise turn 2 is - // lost and a later flush would mismatch. - await ctx.plugin(SessionPersistenceJsonl, { root }) - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - await ctx.fiber.dispose() - }) }) @@ -570,17 +383,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) afterEach(async () => { await ctx.fiber.dispose() }) - it('load rejects a missing session', async () => { - await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/) - }) - - it('append of an empty batch is a no-op', async () => { - const m = meta('empty-batch') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, []) - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) - }) - it('append rejects non-JSON-serializable undefined-producing data', async () => { const m = meta('undef') await ctx.sessionPersistence.create(m) @@ -589,60 +391,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/) }) - it('delete of a non-existent session is a no-op', async () => { - await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined() - }) - - it('an abandoned lazy session (never materialized) releases its id for reuse', async () => { - // A live session is created then disposed BEFORE its first append: cursor 0, - // never materialized, nothing on disk. A new live session reusing the id - // must reclaim it (lazy materialization promises no lingering artifact), - // not wedge on an "already bound" collision until restart. - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - let firstSession!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - firstSession = inner.sessions.create('abandoned', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await backend.inits.get(firstSession) // let the lazy create register the state - await firstFiber.dispose() // disposed before any append → never materialized - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('abandoned', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - // The new session claims the id without error and can persist a turn. - await expect(backend.inits.get(reuse)).resolves.toBeUndefined() - reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', reuse) - const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) - }) - - it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => { - // A session that appended events but was disposed BEFORE its first flush is - // not materialized yet but still holds a write-behind buffer. Reusing the id - // must be rejected (not reclaimed), or the stale buffer would drain against - // the new session — persisting old events under the new id or dropping the - // new session's seq-0 events. - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create('buffered', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await backend.inits.get(first) - // Append a turn but do NOT flush — events sit in the write-behind buffer. - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await firstFiber.dispose() // disposed before flush; not materialized, buffer pending - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('buffered', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(backend.inits.get(reuse)).rejects.toThrow(/already bound to a different live session/) - }) - it('create snapshots its meta: mutating the caller object after the call is ignored', async () => { const m = meta('create-snap', '/orig') const p = ctx.sessionPersistence.create(m) @@ -713,81 +461,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.fiber.dispose() }) - it('resume/adopt: a live session whose id is already on disk continues from the stored length', async () => { - // First lifecycle: persist a session through the store. - const s1 = ctx.sessions.create('resumed', { meta: { cwd: '/r' } }) - for (const e of oneTurnLog()) s1.append(e.type, e.data) - await ctx.parallel('session/flush', s1) - - // Second lifecycle: a NEW backend + a session re-created with the same id - // and SEEDED with the loaded events (the resume path). onCreated must adopt - // the on-disk log (not re-persist the seed), and a new turn appends at seq 6. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - const loaded = await ctx2.sessionPersistence.load(SessionId('resumed')) - const s2 = ctx2.sessions.create('resumed', { seed: loaded.events, meta: { cwd: '/r' } }) - await new Promise(r => setTimeout(r, 10)) // let onCreated adopt - // Append a fresh turn through the live session. - s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await ctx2.parallel('session/flush', s2) - - const reloaded = await ctx2.sessionPersistence.load(SessionId('resumed')) - // 6 original + 2 new, contiguous, no duplicated seed. - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await ctx2.fiber.dispose() - }) - - it('HMR adoption does not crash-repair an active open turn as interrupted', async () => { - const dir = await freshRoot() - const hmr = new Context() - await hmr.plugin(SessionStore) - const first = await hmr.plugin(SessionPersistenceJsonl, { root: dir }) - const session = hmr.sessions.create('hmr-open', { meta: { cwd: '/hmr' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - await hmr.parallel('session/flush', session) - - await first.dispose() - await appendFile(logPath(dir, '/hmr', SessionId('hmr-open')), '{"torn":') - const second = await hmr.plugin(SessionPersistenceJsonl, { root: dir }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await hmr.parallel('session/flush', session) - - const loaded = await hmr.sessionPersistence.load(SessionId('hmr-open')) - expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - await second.dispose() - await hmr.fiber.dispose() - }) - - it('a NEW live session whose id collides with an on-disk log is rejected, not silently adopted', async () => { - // Persist a session on disk. - const s1 = ctx.sessions.create('collide', { meta: { cwd: '/a' } }) - for (const e of oneTurnLog()) s1.append(e.type, e.data) - await ctx.parallel('session/flush', s1) - const before = await readFile(logPath(root, '/a', SessionId('collide')), 'utf8') - - // A FRESH backend + a NEW live session with the same id but NO explicit - // load/resume. onCreated must NOT adopt-from-disk (resume is explicit); it - // treats this as a new session and create() rejects because a log already - // exists on disk. The rejection surfaces via the init promise (flush awaits - // it); the on-disk committed log is left byte-for-byte intact. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - const backend = ctx2.sessionPersistence as unknown as { inits: Map> } - const s2 = ctx2.sessions.create('collide', { meta: { cwd: '/a' } }) - // The init for the new live session rejects (observed via the per-session - // init map and, in production, via flush which awaits the same promise). - await expect(backend.inits.get(s2)).rejects.toThrow(/already has a persisted log on disk/) - // The committed log is untouched (no clobber). - expect(await readFile(logPath(root, '/a', SessionId('collide')), 'utf8')).toBe(before) - await ctx2.fiber.dispose() - }) - it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { // Session A materializes a log under id "reuse". const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { @@ -811,98 +484,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) - it('a live session claims cursor-0 ownerless state created via the public API', async () => { - // create() registers ownerless state with cursor 0 (lazy, nothing persisted - // yet). A live session with that id then arrives and claims it without a - // prefix check (cursor 0 matches trivially), persisting its seed. - await ctx.sessionPersistence.create(meta('lazy-claim', '/a')) - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - let live!: Session - await ctx.plugin(Object.assign((inner: Context) => { - live = inner.sessions.create('lazy-claim', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(backend.inits.get(live)).resolves.toBeUndefined() - live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', live) - const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) - }) - - it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => { - // Materialize a log, then load() it into the backend's state WITHOUT a live - // session — leaving state.owner undefined and cursor at the persisted length - // (the public preview path). - await ctx.sessionPersistence.create(meta('preview', '/a')) - await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog()) - await ctx.sessionPersistence.load(SessionId('preview')) - - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - // A FRESH (empty-seed) live session reusing that id must be rejected: its - // seq 0..cursor-1 events would otherwise be filtered as already-persisted - // and its conversation grafted onto the old log. - let fresh!: Session - await ctx.plugin(Object.assign((inner: Context) => { - fresh = inner.sessions.create('preview', { meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(backend.inits.get(fresh)).rejects.toThrow(/do not match this live session|already has a persisted log/) - }) - - it('a session whose seed matches the loaded prefix claims ownerless state', async () => { - // Materialize a log and load it (ownerless state, cursor = 6). - await ctx.sessionPersistence.create(meta('match', '/a')) - await ctx.sessionPersistence.append(SessionId('match'), oneTurnLog()) - await ctx.sessionPersistence.load(SessionId('match')) - - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - // A live session SEEDED with the persisted log legitimately continues it — - // its seed reproduces the loaded prefix, so it claims the ownerless state. - let cont!: Session - await ctx.plugin(Object.assign((inner: Context) => { - cont = inner.sessions.create('match', { seed: oneTurnLog(), meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await expect(backend.inits.get(cont)).resolves.toBeUndefined() - }) - - it('claiming ownerless state persists the seed suffix beyond the prefix', async () => { - // Materialize a one-turn log and load it (ownerless state, cursor = 6). - await ctx.sessionPersistence.create(meta('suffix-claim', '/a')) - await ctx.sessionPersistence.append(SessionId('suffix-claim'), oneTurnLog()) - await ctx.sessionPersistence.load(SessionId('suffix-claim')) - - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - // A live session seeded with the prefix PLUS a second turn (seqs 6,7). The - // suffix (constructor seed, never emits session/event) must be persisted on - // claim, not lost. - const seed = [ - ...oneTurnLog(), - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] as SessionEvent[] - let cont!: Session - await ctx.plugin(Object.assign((inner: Context) => { - cont = inner.sessions.create('suffix-claim', { seed, meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await backend.inits.get(cont) - const loaded = await ctx.sessionPersistence.load(SessionId('suffix-claim')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - }) - - it('claiming cursor-0 ownerless state persists the whole constructor seed', async () => { - // create() registers ownerless state with cursor 0 (lazy, nothing on disk). - await ctx.sessionPersistence.create(meta('lazy-seed', '/a')) - const backend = ctx.sessionPersistence as unknown as { inits: Map> } - // A live session seeded with a full turn claims it; the whole seed (cursor - // is 0) must be persisted. - let cont!: Session - await ctx.plugin(Object.assign((inner: Context) => { - cont = inner.sessions.create('lazy-seed', { seed: oneTurnLog(), meta: { cwd: '/a' } }) - }, { inject: ['sessions'] })) - await backend.inits.get(cont) - const loaded = await ctx.sessionPersistence.load(SessionId('lazy-seed')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) - }) - it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => { // Materialize and load (ownerless, cursor = 6). await ctx.sessionPersistence.create(meta('divergent', '/a')) @@ -942,14 +523,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/) }) - it('round-trips a header with parentSession (fork lineage)', async () => { - const m: SessionHeader = { version: 1, id: SessionId('forked-child'), createdAt: 1, parentSession: SessionId('the-parent') } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.parentSession).toBe('the-parent') - }) - it('list returns nothing when the root directory does not exist', async () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) @@ -1025,48 +598,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' }) }) - it('initFor is idempotent: a re-seeded existing session is not re-initialized', async () => { - const session = ctx.sessions.create('idem', { meta: { cwd: '/i' } }) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - // Re-emit session/created for the SAME live session (idempotent initFor). - ctx.emit('session/created', session) - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('idem')) - expect(loaded.events).toHaveLength(2) // not doubled - }) - - - it('flush before init resolves with no state uses cursor 0', async () => { - // Drive a fork (seed) flush where the buffer holds the seed; the fresh - // events filter against cursor. Exercises the state-undefined cursor path. - const session = ctx.sessions.create('flush-nostate') - // Append directly to the live session and flush IMMEDIATELY, before the - // async onCreated init has necessarily set state. - session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) - expect(loaded.events).toHaveLength(2) - }) - - it('createCore rejects creating an id this backend already tracks', async () => { - await ctx.sessionPersistence.create(meta('dup')) - await expect(ctx.sessionPersistence.create(meta('dup'))).rejects.toThrow(/already exists in this backend/) - }) - - it('createCore rejects creating an id whose log already exists on disk', async () => { - const m = meta('on-disk', '/od') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - // A fresh backend (no in-memory state) must refuse to create over the log. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - await expect(ctx2.sessionPersistence.create(meta('on-disk', '/od'))).rejects.toThrow(/already has a persisted log on disk/) - await ctx2.fiber.dispose() - }) it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => { // Persist the id under cwd A. diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence-sqlite/src/index.ts index 34ed6213da..49cf3882d4 100644 --- a/packages/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence-sqlite/src/index.ts @@ -1,19 +1,18 @@ /** * SQLite durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`). * - * A SECOND {@link SessionPersistence} implementation, built to validate that - * the abstract seam + the shared `runPersistenceContract` suite are genuinely + * A SECOND {@link SessionPersistence} implementation, built to validate that the + * abstract seam + the shared `runPersistenceContract` suite are genuinely * backend-agnostic: the same append-only / contiguous-seq / lazy-materialization * / interrupted-turn-close-on-load semantics the JSONL backend expresses over * file bytes, expressed here over `node:sqlite` rows. Each `SessionEvent` maps - * 1:1 onto a row `(session_id, seq, type, time, data)`; `append` is an INSERT - * inside a transaction that asserts the contiguous-seq contract. + * 1:1 onto a row `(session_id, seq, type, time, data)`. * - * Like the JSONL backend it is also the write-path plugin: it installs the - * `session/event` → buffer → `session/flush` drain, persists a fork's seed once - * on `session/created`, keeps a per-session write cursor so a resumed session - * never re-appends stored events, and seeds existing live sessions on apply - * (HMR does not replay `session/created`). + * Like the JSONL backend it supplies ONLY the storage primitives (the + * {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside + * transactions); all the write-path orchestration lives in the backend-agnostic + * {@link PersistenceCoordinator} this class composes. The six public + * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -24,9 +23,9 @@ import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { - SessionPersistence, assertSerializable, seedCoversPrefix, + SessionPersistence, PersistenceCoordinator, + type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, @@ -44,55 +43,36 @@ export interface Config { path: string } -/** Backend bookkeeping for a session id (NOT the live Session object). */ -interface SessionState { - meta: SessionHeader - /** Next seq to write — equals the number of committed events. */ - cursor: number - /** Whether the session has at least one persisted event (materialized). */ - materialized: boolean - /** The live Session that owns this state (collision detection); see onCreated. */ - owner?: Session -} - -async function settledErrors(promises: Iterable>): Promise { - const settled = await Promise.allSettled([...promises]) - const errors: unknown[] = [] - for (const result of settled) { - if (result.status === 'rejected') errors.push(result.reason) - } - return errors -} - /** * The SQLite persistence backend. Load as a plugin; it registers as - * `ctx.sessionPersistence` and installs the write-path listeners. + * `ctx.sessionPersistence` and (via the coordinator) installs the write-path + * listeners. Its torn-tail marker is the seq to delete from. */ -export class SessionPersistenceSqlite extends SessionPersistence { +export class SessionPersistenceSqlite extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] static Config: z = z.object({ path: z.string().required(), }) + /** + * Backend label for the coordinator's dispose diagnostics. Intentionally + * shadows cordis `Service.name` (set to `'sessionPersistence'` by the base); + * see the JSONL backend for why this does not affect service resolution. + */ + override readonly name = 'session-persistence-sqlite' + private db!: DatabaseSync private ready: Promise - /** Backend bookkeeping keyed by session id (NOT the live Session object). */ - private states = new Map() - /** Write-behind buffers keyed by the live Session (write path). */ - private buffers = new Map() - /** Per-session serialization chain (keyed by session id). */ - private chains = new Map>() - /** Per-session init promise (onCreated), keyed by the LIVE Session object. */ - private inits = new Map>() + private coordinator: PersistenceCoordinator constructor(ctx: Context, public config: Config) { super(ctx) // Open the database asynchronously (the parent directory may need creating); - // every backend op awaits `ready` first. Opening synchronously in the ctor - // would force a sync mkdir and block plugin apply. + // every hook awaits `ready` first. Opening synchronously would force a sync + // mkdir and block plugin apply. this.ready = this.openDb(config.path) - this.installWritePath() + this.coordinator = new PersistenceCoordinator(this.ctx, this) } private async openDb(path: string): Promise { @@ -105,229 +85,156 @@ export class SessionPersistenceSqlite extends SessionPersistence { } } - // --- SessionPersistence backend surface (all serialized per session id) --- + // --- SessionPersistence service surface (delegated to the coordinator) --- create(meta: SessionHeader): Promise { - const snapshot: SessionHeader = { ...meta } - return this.serialize(snapshot.id, () => this.createCore(snapshot)) + return this.coordinator.create(meta) } - private async createCore(meta: SessionHeader): Promise { + append(id: SessionId, events: readonly SessionEvent[]): Promise { + return this.coordinator.append(id, events) + } + + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.load(id) + } + + has(id: SessionId): Promise { + return this.coordinator.has(id) + } + + delete(id: SessionId): Promise { + return this.coordinator.delete(id) + } + + // `list` is BOTH the public service method and the PersistenceBackend hook — + // one method (the SELECT below). The coordinator adds no orchestration for + // listing, so routing it through the coordinator would just recurse. Defined + // once, in the "PersistenceBackend hooks" section. + + /** + * The per-session init promises, exposed for white-box tests that await a + * specific session's onCreated (there is no public API to await one init). + */ + get inits(): Map> { + return this.coordinator.inits + } + + // --- PersistenceBackend hooks (the SQLite storage primitives) --- + + /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ + loadStored(id: SessionId): Promise | undefined> { + return this.readPrefix(id) + } + + /** Read a stored prefix; `cwd` is ignored (the id is globally unique in SQLite). */ + loadLive(id: SessionId, _cwd: string | undefined): Promise | undefined> { + return this.readPrefix(id) + } + + /** + * Read a session's row + ordered events into a {@link StoredPrefix}. The + * torn-tail marker is the seq from which a never-committed tail must be deleted + * (`scanRows` already returns it as `number | undefined`). + */ + private async readPrefix(id: SessionId): Promise | undefined> { await this.ready - if (this.states.has(meta.id)) { - throw new Error(`session "${meta.id}" already exists in this backend`) - } - if (this.rowFor(meta.id) !== undefined) { - throw new Error(`session "${meta.id}" already has a persisted row; load/resume it instead of creating`) - } - // Lazy: record intent in memory only. No row until the first append, so an - // abandoned (never-appended) session leaves nothing behind and stays absent - // from has()/list(). - this.states.set(meta.id, { meta, cursor: 0, materialized: false }) + const row = this.rowFor(id) + if (row === undefined) return undefined + const meta = rowToMeta(row) + const eventRows = this.db + .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq') + .all(id) as unknown as EventRow[] + const { preserved, tornFrom } = scanRows(eventRows) + return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} } } - // `async` so the synchronous validate/clone below reject (not throw) per the - // Promise contract — callers use `await expect(...).rejects`. - async append(id: SessionId, events: readonly SessionEvent[]): Promise { - // Validate serializability BEFORE cloning so a bad event surfaces the typed - // "non-JSON-serializable" error rather than an opaque DataCloneError from - // structuredClone. Then deep-snapshot the batch HERE, before the op waits - // behind the per-session chain: a caller that passes a live array (e.g. - // session.events) and mutates it — OR mutates an event inside it — before - // the op runs would otherwise have those changes persisted, or advance the - // cursor past what was written. The clone is taken at call time (before the - // first await), matching the JSONL backend. - assertSerializable(events) - const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this.appendCore(id, batch)) - } - - private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + /** + * Durably append a batch in ONE transaction: materialize the sessions row (if + * lazy) and INSERT every event, or roll back entirely. The transaction is the + * atomicity + durability boundary, so a mid-batch failure (a UNIQUE violation + * on a duplicated seq) leaves the stored log untouched. + */ + async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ready - if (events.length === 0) return - let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) - - // Contiguity contract: each event's seq must continue the stored log. - for (const [i, event] of events.entries()) { - if (event.seq !== state.cursor + i) { - throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`) - } - } - - // The transaction is the durability + atomicity boundary: materialize the - // sessions row (if lazy) and INSERT every event, or roll back entirely. A - // BEGIN/COMMIT around the batch means a mid-batch failure (a UNIQUE - // violation on a duplicated seq from a concurrent writer) leaves the stored - // log untouched, so the cursor stays truthful and a retry is clean. (A crash - // tail is already gone: load() physically deletes the torn fragment and - // durably closes the interrupted turn before returning, so by the time any - // append runs the stored log is balanced and contiguous.) const insertEvent = this.db.prepare( 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)', ) this.db.exec('BEGIN') try { - if (!state.materialized) this.writeRow(state.meta) + if (!isMaterialized) this.writeRow(meta) for (const event of events) { - insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data)) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data)) } this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') throw error } - state.materialized = true - state.cursor += events.length } - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this.loadCore(id)) - } - - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + /** + * Make a crash repair durable in ONE transaction: DELETE the torn tail (from + * `tornMarker`) and INSERT the synthetic `closers`. After COMMIT the stored rows + * == the balanced log. + */ + async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise { await this.ready - const row = this.rowFor(id) - if (row === undefined) throw new Error(`session "${id}" not found`) - const meta = rowToMeta(row) - this.assertVersion(meta) - - // Read every stored row ordered by seq, then scan for the preserved prefix: - // the longest seq-contiguous, parseable run, INCLUDING the real events of an - // interrupted final turn after the last turn/end (a turn can be huge — they - // are never truncated). scanRows works off the seq+type COLUMNS for the - // last-turn/end boundary, so a malformed `data` in a torn tail row is - // discarded (not unloadable); only a parse error / seq gap in the COMMITTED - // region (at or before the last turn/end) throws (genuine corruption). - const eventRows = this.db - .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] - const { preserved, tornFrom } = scanRows(eventRows) - - // Crash-recovery (mutating load, same as the JSONL backend): if the log ended - // mid-turn, close it DURING load so disk, the returned log, and the cursor all - // agree — both append routes then continue with no special-casing. Synthesize - // the boundary events (a step/end if a step was open, then a - // turn/end {kind:'interrupted'}); the interrupted turn's real events are - // preserved, never truncated (the session-persistence RFC). - const closers = interruptedTurnClosers(preserved) - const balanced = [...preserved, ...closers] - - // Physically repair the stored log inside one transaction: DELETE the torn - // tail fragment (if any), then INSERT the synthetic closers. After COMMIT the - // stored rows == balanced, so the cursor is truthful and the next append - // continues cleanly with no deferred repair. The metadata row stays as-is - // even when preserved.length === 0 (an all-tail crash): the session WAS - // materialized by the partial append, so has()/list() still report it — the - // same as the JSONL backend, whose file likewise survives a first append that - // never reached turn/end. - if (tornFrom !== undefined || closers.length > 0) { - this.db.exec('BEGIN') - try { - if (tornFrom !== undefined) { - this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(id, tornFrom) - } - if (closers.length > 0) { - const insertEvent = this.db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - for (const event of closers) { - insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data)) - } - } - this.db.exec('COMMIT') - } catch (error) { - // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved - // or deleted as torn first); this rolls back a DB-level failure (disk - // full, etc.), unreachable in test. - /* v8 ignore start */ - this.db.exec('ROLLBACK') - throw error - /* v8 ignore stop */ + this.db.exec('BEGIN') + try { + if (tornMarker !== undefined) { + this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(meta.id, tornMarker) } + if (closers.length > 0) { + const insertEvent = this.db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') + for (const event of closers) { + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data)) + } + } + this.db.exec('COMMIT') + } catch (error) { + // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or + // deleted as torn first); this rolls back a DB-level failure (disk full, + // etc.), unreachable in test. + /* v8 ignore start */ + this.db.exec('ROLLBACK') + throw error + /* v8 ignore stop */ } - - // Record state at the balanced length. The state keeps its OWN copy of the - // meta; the returned value is separate so a consumer mutating loaded.meta - // cannot corrupt the backend's row metadata. - this.states.set(id, { - meta: { ...meta }, - cursor: balanced.length, - materialized: true, - }) - return { meta, events: balanced } } - private async adoptLiveStoredPrefix(session: Session, seed: readonly SessionEvent[]): Promise { + /** Remove a session's row (ON DELETE CASCADE drops its events). */ + async deleteStored(id: SessionId): Promise { await this.ready - const row = this.rowFor(session.header.id) - /* v8 ignore next -- caller checked row existence */ - if (row === undefined) throw new Error(`session "${session.header.id}" not found`) - const meta = rowToMeta(row) - this.assertVersion(meta) - - const rows = this.db - .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq') - .all(session.header.id) as unknown as EventRow[] - const { preserved, tornFrom } = scanRows(rows) - if (!seedCoversPrefix(seed, preserved)) { - throw new Error(`session "${session.header.id}" already has a persisted log that does not match this live session (id collision)`) - } - - if (tornFrom !== undefined) { - this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(session.header.id, tornFrom) - } - this.states.set(session.header.id, { - meta: { ...meta }, - cursor: preserved.length, - materialized: true, - owner: session, - }) - const suffix = seed.slice(preserved.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id) } + /** List all materialized sessions' metadata (every row is a materialized session). */ async list(): Promise { await this.ready - // Every metadata row is a materialized session: the row is written only by - // the first append (a created-but-never-appended session has no row), so - // listing all rows is exactly the materialized set. const rows = this.db .prepare('SELECT * FROM sessions') .all() as unknown as SessionRow[] return rows.map(rowToMeta) } - async has(id: SessionId): Promise { + /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ + async close(): Promise { await this.ready - const state = this.states.get(id) - if (state?.materialized) return true - // A metadata row exists iff the session was materialized by a first append. - return this.rowFor(id) !== undefined - } - - delete(id: SessionId): Promise { - return this.serialize(id, () => this.deleteCore(id)) - } - - private async deleteCore(id: SessionId): Promise { - await this.ready - // ON DELETE CASCADE drops the session's events with its row. - this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id) - this.states.delete(id) + this.db.close() } // --- row helpers --- /** Fetch a session's row, or undefined if absent. */ private rowFor(id: SessionId): SessionRow | undefined { - const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined - return row + return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined } /** * Insert-or-replace a session's metadata row. The only caller is the first - * materializing `append`, so writing the row IS the materialization (its - * existence is the signal `has`/`list` read); a never-appended session has no - * row at all. + * materializing `appendBatch`, so writing the row IS the materialization (its + * existence is the signal `has`/`list` read). */ private writeRow(meta: SessionHeader): void { this.db.prepare(` @@ -346,190 +253,6 @@ export class SessionPersistenceSqlite extends SessionPersistence { meta.parentSession ?? null, ) } - - /** Build a state for a session present in the DB but not yet in memory. */ - private async adopt(id: SessionId): Promise { - await this.loadCore(id) // sets the state; load (serialized) would deadlock - const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ - if (!state) throw new Error(`failed to adopt session "${id}"`) - return state - } - - private assertVersion(meta: SessionHeader): void { - if (meta.version !== 1) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) - } - } - - /** - * Run `op` after any in-flight operation for the same session id, so writes - * for one session never interleave. Errors do not poison the chain. NOTE: - * serialized public methods must NOT call each other (deadlock); they call - * the unserialized `*Core` helpers instead. - */ - private serialize(id: SessionId, op: () => Promise): Promise { - const prior = this.chains.get(id) ?? Promise.resolve() - const next = prior.then(op, op) - this.chains.set(id, next.then(() => undefined, () => undefined)) - return next - } - - // --- write path (session/event → flush drain) --- - - private installWritePath(): void { - const ctx = this.ctx - - ctx.on('session/created', (session) => { void this.initFor(session) }) - - // Snapshot + buffer every event (the live object is mutable; clone so a - // later in-place mutation cannot rewrite a buffered event). Serializability - // is guaranteed at the source (Session.append), so structuredClone is safe. - ctx.on('session/event', (session, event) => { - let buffer = this.buffers.get(session) - if (!buffer) this.buffers.set(session, buffer = []) - buffer.push(structuredClone(event)) - }) - - ctx.on('session/flush', session => this.flush(session)) - - // Dispose must reach quiescence: await every init + final drain, then close - // the database, BEFORE returning, so no write lands after teardown. - ctx.effect(() => async () => { - let disposeError: unknown - try { - const errors = [ - ...await settledErrors(this.inits.values()), - ...await settledErrors([...this.buffers.keys()].map(s => this.flush(s))), - ...await settledErrors(this.chains.values()), - ] - if (errors.length > 0) { - throw new AggregateError(errors, 'session-persistence-sqlite dispose failed') - } - } catch (error: unknown) { - disposeError = error - throw error - } finally { - try { - await this.ready - this.db.close() - } catch (error: unknown) { - /* v8 ignore next -- open/close failure racing disposal is a defensive teardown edge */ - if (disposeError === undefined) throw error - // Opening/closing the database can only add teardown context here; keep - // the already-captured init/flush/chain AggregateError as the primary - // disposal failure instead of masking it from callers. - } - } - }, 'session-persistence-sqlite write path') - - // HMR: a hot reload does not replay session/created, so seed existing live - // sessions (mirrors dsh-invariants and the JSONL backend). - for (const session of ctx.sessions.list()) void this.initFor(session) - } - - /** Start (once) the async init for a session and remember its promise. */ - private initFor(session: Session): Promise { - const existing = this.inits.get(session) - if (existing) return existing - const seed = session.events.map(e => structuredClone(e)) - const p = this.onCreated(session, seed) - p.catch(() => { /* observed by flush/dispose via the stored promise */ }) - this.inits.set(session, p) - return p - } - - /** - * On session/created: sync the backend's state to a live Session. Cases - * mirror the JSONL backend: - * 1. Already tracked → no-op (or claim ownerless state if the seed matches). - * 2. A row EXISTS and is a seq-aligned PREFIX of the live events → adopt - * (HMR/resume), persisting any live suffix beyond the stored prefix. - * 3. A row EXISTS but is NOT a prefix → reject (id collision). - * 4. No row → a genuinely new session: register meta (lazy) + persist seed. - */ - private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise { - await this.ready - const id = session.header.id - const tracked = this.states.get(id) - if (tracked !== undefined) { - /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */ - if (tracked.owner === session) return - if (tracked.owner === undefined) { - // Ownerless state from a public create()/load(). The first live session - // claims it ONLY if its seed reproduces the persisted prefix. - if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { - throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) - } - tracked.owner = session - const suffix = seed.slice(tracked.cursor) - if (suffix.length > 0) await this.append(id, suffix) - return - } - // Owned by a DIFFERENT live session. Reclaim ONLY a truly-abandoned id - // (never materialized, no pending buffer); else it is a real collision. - const ownerBuffer = this.buffers.get(tracked.owner) - if (!tracked.materialized && !ownerBuffer?.length) { - this.states.delete(id) - } else { - throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) - } - } - - const row = this.rowFor(id) - if (row !== undefined) { - // Adopt a LIVE prefix without crash-repairing an open turn as interrupted; - // HMR may still append the real completion from the live Session. - await this.serialize(id, () => this.adoptLiveStoredPrefix(session, seed)) - return - } - - // case 4: a genuinely new session. - const meta: SessionHeader = { ...session.header } - await this.create(meta) - const created = this.states.get(id) - /* v8 ignore next -- create() always sets the state for the id */ - if (created !== undefined) created.owner = session - if (seed.length > 0) await this.append(id, seed) - } - - /** The preserved events for a session id (torn tail excluded, turn NOT yet closed). */ - private eventsFor(id: SessionId): SessionEvent[] { - const rows = this.db - .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] - // Scan on seq+type columns, parsing `data` only for the preserved prefix (a - // malformed torn tail must not throw here — same as loadCore). Returns the - // preserved events WITHOUT the synthetic closers, so a collision check - // compares a live seed against the real on-disk events, mirroring the JSONL - // backend's scanLog use in onCreated. - return scanRows(rows).preserved - } - - /** Whether a live session's seed reproduces the first `cursor` stored events. */ - private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise { - await this.ready - if (cursor === 0) return true - return seedCoversPrefix(seed, this.eventsFor(id).slice(0, cursor)) - } - - private async flush(session: Session): Promise { - await this.inits.get(session) - await this.serialize(session.header.id, () => this.drain(session)) - } - - /** Drain a session's write buffer to the database. Caller serializes per id. */ - private async drain(session: Session): Promise { - const buffer = this.buffers.get(session) - if (!buffer?.length) return - const batch = buffer.slice() - const state = this.states.get(session.header.id) - /* v8 ignore next -- state is always set by the awaited init before flush */ - const cursor = state?.cursor ?? 0 - const fresh = batch.filter(e => e.seq >= cursor) - if (fresh.length > 0) await this.appendCore(session.header.id, fresh) - buffer.splice(0, batch.length) - } } export default SessionPersistenceSqlite diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence-sqlite/tests/sqlite.spec.ts index 6cdc0dcb7a..aecfa5665d 100644 --- a/packages/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence-sqlite/tests/sqlite.spec.ts @@ -3,11 +3,12 @@ import { Context } from 'cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) @@ -38,6 +39,31 @@ runPersistenceContract('sqlite', async () => { } }) +// Run the shared coordinator orchestration suite against the real SQLite backend. +// A FILE-backed db (not :memory:) is the shared storage scope so two mounted +// instances see the same rows (HMR/reload). `corruptTail` INSERTs a row past the +// committed seq whose `data` is invalid JSON — a never-committed torn tail that +// drives the coordinator's commitRepair-with-tornMarker branch over real db rows. +runCoordinatorContract('sqlite', async (): Promise => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) + const path = join(dir, 'sessions.db') + return { + mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }), + corruptTail: async (id) => { + // 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 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 (?, ?, ?, ?, ?)') + .run(id, next, 'assistant/chunk', 99, '{not valid json') + db.close() + }, + cleanup: async () => { await rm(dir, { recursive: true, force: true }) }, + } +}) + describe('scanRows', () => { // scanRows works off EventRows (data is a JSON string column); build them from // SessionEvents so the unit tests read in terms of the event vocabulary. @@ -108,35 +134,6 @@ describe('scanRows', () => { }) }) -describe('SessionPersistenceSqlite: HMR adoption', () => { - it('does not crash-repair an active open turn as interrupted', async () => { - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - const first = await ctx.plugin(SessionPersistenceSqlite, { path }) - const session = ctx.sessions.create('hmr-open', { meta: { cwd: '/hmr' } }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - await ctx.parallel('session/flush', session) - - await first.dispose() - const db = openDatabase(path) - db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') - .run('hmr-open', 2, 'step/end', 2, '{"torn":') - db.close() - const second = await ctx.plugin(SessionPersistenceSqlite, { path }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) - expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - await second.dispose() - await ctx.fiber.dispose() - }) -}) - describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { const path = await freshDbPath() @@ -251,31 +248,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/) }) - it('append snapshots the batch: mutating an event after the call does not corrupt the persisted copy', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - const m = meta('snapshot') - await ctx.sessionPersistence.create(m) - const batch: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } } }, - { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - const p = ctx.sessionPersistence.append(m.id, batch) - // Mutate the live array AND an event's data AFTER the call but before it - // drains behind the per-session chain. The snapshot taken at call time must - // shield the persisted copy. - ;(batch[1]!.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' - batch.push({ type: 'user/message', seq: 3, time: 4, data: { content: [{ type: 'text', text: 'injected' }], source: { kind: 'user' } } }) - await p - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.events).toHaveLength(3) // the pushed event was not persisted - const um = loaded.events[1] - expect(um?.type === 'user/message' && (um.data.content[0] as { text: string }).text).toBe('original') - await fiber.dispose() - }) - it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => { const path = await freshDbPath() const m = meta('corrupt-tail') @@ -344,183 +316,12 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await fiber2.dispose() }) - it('rejects an unknown format version on load', async () => { - const path = await freshDbPath() - // Materialize a row with version 2 directly via the real schema. - const db = openDatabase(path) - db.prepare('INSERT INTO sessions (id, version, created_at) VALUES (?, ?, ?)') - .run('v2', 2, 1) - db.close() - - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path }) - await expect(ctx.sessionPersistence.load(SessionId('v2'))).rejects.toThrow(/version 2/) - await fiber.dispose() - }) - - it('create rejects a duplicate id (in memory and on a persisted row)', async () => { - const path = await freshDbPath() - const m = meta('dup') - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path }) - await ctx.sessionPersistence.create(m) - // Same in-memory state. - await expect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await fiber.dispose() - - // A fresh instance over the same file sees the persisted row. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path }) - await expect(ctx2.sessionPersistence.create(m)).rejects.toThrow(/already has a persisted row/) - await fiber2.dispose() - }) - it('exposes the schema version constant', () => { expect(SCHEMA_VERSION).toBe(2) }) }) -describe('SessionPersistenceSqlite: write path (session/event → flush)', () => { - function send(session: Session, events: SessionEvent[]): void { - for (const e of events) session.append(e.type, e.data) - } - - it('persists a turn appended through the live session on flush', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - const session = ctx.sessions.create('w1') - send(session, oneTurnLog()) - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('w1')) - expect(loaded.events.map(e => e.type)).toEqual(oneTurnLog().map(e => e.type)) - await fiber.dispose() - }) - - it('a resumed session does not re-append its seed', async () => { - const path = await freshDbPath() - // Run 1: persist a full turn through the live session. - const ctx1 = new Context() - await ctx1.plugin(SessionStore) - const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path }) - const s1 = ctx1.sessions.create('resume') - for (const e of oneTurnLog()) s1.append(e.type, e.data) - await ctx1.parallel('session/flush', s1) - await fiber1.dispose() - - // Run 2: reconstruct the live session from the loaded log (seed), then add a - // second turn. The seed must NOT be re-appended (no UNIQUE collision), and - // the second turn continues the seq. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path }) - const { events } = await ctx2.sessionPersistence.load(SessionId('resume')) - const s2 = ctx2.sessions.create('resume', { seed: events }) - s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await ctx2.parallel('session/flush', s2) - const reloaded = await ctx2.sessionPersistence.load(SessionId('resume')) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await fiber2.dispose() - }) - - it('HMR: applying the plugin seeds existing live sessions', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create('hmr') - for (const e of oneTurnLog()) session.append(e.type, e.data) - // Plugin applied AFTER the session already has events. - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - await ctx.parallel('session/flush', session) - expect(await ctx.sessionPersistence.has(SessionId('hmr'))).toBe(true) - await fiber.dispose() - }) - - it('dispose drains a pending buffer before closing the database', async () => { - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceSqlite, { path }) - const session = ctx.sessions.create('drain') - for (const e of oneTurnLog()) session.append(e.type, e.data) - // No explicit flush — dispose must drain the buffer. - await fiber.dispose() - - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path }) - expect(await ctx2.sessionPersistence.has(SessionId('drain'))).toBe(true) - await fiber2.dispose() - }) - - it('rejects a different live session colliding on a persisted id', async () => { - const path = await freshDbPath() - const ctx1 = new Context() - await ctx1.plugin(SessionStore) - const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path }) - const s1 = ctx1.sessions.create('collide') - for (const e of oneTurnLog()) s1.append(e.type, e.data) - await ctx1.parallel('session/flush', s1) - await fiber1.dispose() - - // A fresh, unrelated session reusing the id (no seed) must be rejected. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path }) - const s2 = ctx2.sessions.create('collide') - s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await expect(ctx2.parallel('session/flush', s2)).rejects.toThrow(/id collision/) - await fiber2.dispose() - }) -}) - describe('SessionPersistenceSqlite: edge cases', () => { - it('append of an empty batch is a no-op', async () => { - const { ctx, dispose } = await backend() - const m = meta('empty-batch') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, []) - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) // still lazy - await dispose() - }) - - it('load rejects a missing session', async () => { - const { ctx, dispose } = await backend() - await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/) - await dispose() - }) - - it('delete of a non-existent session is a no-op', async () => { - const { ctx, dispose } = await backend() - await ctx.sessionPersistence.delete(SessionId('ghost')) - expect(await ctx.sessionPersistence.has(SessionId('ghost'))).toBe(false) - await dispose() - }) - - it('append adopts a session that exists only in the DB (fresh instance)', async () => { - const path = await freshDbPath() - const m = meta('adopt-append') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) - await b1.dispose() - - // A fresh instance appends a second turn WITHOUT a prior create/load: append - // must adopt the on-disk row (cursor = stored length) and continue the seq. - const b2 = await backend(path) - await b2.ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ]) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await b2.dispose() - }) - it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') @@ -549,190 +350,6 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b2.dispose() }) - it('round-trips a header with parentSession (fork lineage)', async () => { - const { ctx, dispose } = await backend() - const m: SessionHeader = { ...meta('child'), parentSession: SessionId('parent') } - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const loaded = await ctx.sessionPersistence.load(m.id) - expect(loaded.meta.parentSession).toBe(SessionId('parent')) - await dispose() - }) - - it('a fresh live session reusing a previously-loaded id is rejected (ownerless guard)', async () => { - const path = await freshDbPath() - const m = meta('ownerless') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) - await b1.dispose() - - const b2 = await backend(path) - // load() leaves ownerless state with cursor 6. - await b2.ctx.sessionPersistence.load(m.id) - // A fresh, unrelated live session reusing the id has a shorter/non-matching - // seed → its onCreated must reject rather than graft onto the loaded prefix. - const s = b2.ctx.sessions.create('ownerless') - s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await expect(b2.ctx.parallel('session/flush', s)).rejects.toThrow(/id collision/) - await b2.dispose() - }) - - it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => { - const path = await freshDbPath() - const m = meta('claim') - const b1 = await backend(path) - await b1.ctx.sessionPersistence.create(m) - await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) - await b1.dispose() - - const b2 = await backend(path) - const { events } = await b2.ctx.sessionPersistence.load(m.id) // ownerless, cursor 6 - // A live session seeded with the loaded log PLUS a new turn claims the state - // and persists only the suffix. - const s = b2.ctx.sessions.create('claim', { seed: [ - ...events, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ] }) - await b2.ctx.parallel('session/flush', s) - const loaded = await b2.ctx.sessionPersistence.load(m.id) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) - await b2.dispose() - }) - - it('an abandoned lazy session (never materialized) releases its id for reuse', async () => { - const { ctx, dispose } = await backend() - const inits = (ctx.sessionPersistence as unknown as { inits: Map> }).inits - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create('reuse') - }, { inject: ['sessions'] })) - await inits.get(first) // let the lazy create register the state - await firstFiber.dispose() // disposed before any append → never materialized - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('reuse') - }, { inject: ['sessions'] })) - await expect(inits.get(reuse)).resolves.toBeUndefined() - reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', reuse) - expect(await ctx.sessionPersistence.has(SessionId('reuse'))).toBe(true) - await dispose() - }) - - it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => { - const { ctx, dispose } = await backend() - const inits = (ctx.sessionPersistence as unknown as { inits: Map> }).inits - let first!: Session - const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create('buffered') - }, { inject: ['sessions'] })) - await inits.get(first) - // Append a turn but do NOT flush — events sit in the write-behind buffer. - first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await firstFiber.dispose() // disposed before flush; not materialized, buffer pending - - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('buffered') - }, { inject: ['sessions'] })) - await expect(inits.get(reuse)).rejects.toThrow(/already bound to a different live session/) - await dispose() - }) - - it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => { - const { ctx, dispose } = await backend() - const session = ctx.sessions.create('idem') - ctx.emit('session/created', session) // second create event for the same object - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - expect(await ctx.sessionPersistence.has(SessionId('idem'))).toBe(true) - await dispose() - }) - - it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => { - const { ctx, dispose } = await backend() - // create() registers ownerless state with cursor 0 (no events yet). - await ctx.sessionPersistence.create(meta('cursor0')) - // A live session reusing that id, seeded with a turn, claims the ownerless - // state (cursor 0 trivially matches any seed) and persists the whole seed. - const s = ctx.sessions.create('cursor0', { seed: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - ] }) - await ctx.parallel('session/flush', s) - const loaded = await ctx.sessionPersistence.load(SessionId('cursor0')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) - await dispose() - }) - - it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => { - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - // The session lives in its OWN fiber so it survives the backend reload. - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-adopt') - }, { inject: ['sessions'] })) - - // Backend instance 1 materializes the session on disk. - const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - // Hot-reload: dispose instance 1, plug in instance 2 over the SAME file - // while the session stays live. Instance 2 has an empty states map but the - // row is materialized on disk and is a prefix of the live events — it must - // ADOPT (not reject), and a second turn then persists. - await backend1.dispose() - await ctx.plugin(SessionPersistenceSqlite, { path }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() - - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - await ctx.fiber.dispose() - }) - - it('HMR: adoption persists the live SUFFIX that was ahead of the on-disk prefix', async () => { - const path = await freshDbPath() - const ctx = new Context() - await ctx.plugin(SessionStore) - let session!: Session - await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-suffix') - }, { inject: ['sessions'] })) - - const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) - - // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT - // flushing turn 2: it is now ONLY in the live session's events. - await backend1.dispose() - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - - // Instance 2 adopts the on-disk prefix (turn 1) and MUST persist the live - // suffix (turn 2) carried in the session's events. - await ctx.plugin(SessionPersistenceSqlite, { path }) - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) - expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) - await ctx.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/session-persistence/README.md b/packages/session-persistence/README.md index 521020e80d..a63ea6284a 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -21,11 +21,31 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l - **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). - **Durability.** `append` returns only once the batch is durable. +## The write coordinator + +The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). + +`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). + +The `PersistenceBackend` hooks (the only seam between the coordinator and storage): + +| Hook | Role | +|---|---| +| `name` | Backend label for the dispose-failure `AggregateError`. | +| `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. | +| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | +| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | +| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | +| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | +| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | + +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md). + ## Testing backends -Import `runPersistenceContract` from `tests/contract.ts` and call it with a factory that yields a fresh, empty backend plus a teardown. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics; a backend's own spec adds implementation-specific tests (crash repair, path sanitization) on top. +Import `runPersistenceContract` from `tests/contract.ts` (the public-API contract) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. -Two backends run this suite: `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data)`). Both passing the same contract is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. +Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. ## Metadata types diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/src/coordinator.ts new file mode 100644 index 0000000000..09bc6eb943 --- /dev/null +++ b/packages/session-persistence/src/coordinator.ts @@ -0,0 +1,556 @@ +/** + * The backend-agnostic write-path orchestration shared by every first-party + * {@link SessionPersistence} backend. + * + * The two durable backends (`dsh-session-persistence-jsonl` over file bytes, + * `dsh-session-persistence-sqlite` over `node:sqlite` rows) were byte-identical + * — or same-algorithm — for ALL of their orchestration: the in-memory + * bookkeeping (the per-id state, the write-behind buffers, the per-id + * serialization chains, the per-session init promises), the `session/event` → + * buffer → `session/flush` drain, lazy materialization, crash-tail repair on + * load, the four `session/created` adoption cases (new / HMR-adopt / collision / + * ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives + * differed (write bytes vs. INSERT rows). {@link PersistenceCoordinator} owns + * the orchestration once; a backend supplies the storage primitives as a small + * {@link PersistenceBackend} hook object. + * + * The abstract {@link SessionPersistence} service's public API is unchanged: a + * backend still IS a `SessionPersistence` (its six public methods delegate to a + * coordinator it composes), so a third-party backend MAY implement the service + * directly without using the coordinator at all. + * + * See the write-coordinator RFC (docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md) + * for the design rationale (composition over inheritance, the opaque torn marker). + * + * @module @deepseek-ai/dsh-session-persistence/coordinator + */ + +import { Context } from 'cordis' +import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { assertSerializable, seedCoversPrefix } from './index.ts' + +/** + * A stored session's durable prefix as read back from a backend: its + * {@link SessionHeader}, the preserved (seq-contiguous, parseable) event prefix, + * and an OPAQUE `tornMarker` that is present iff a never-committed torn tail must + * be truncated before further writes. + * + * The coordinator NEVER inspects `tornMarker`'s value — it only tests + * `!== undefined` (is there a tail to repair?) and passes the value back to + * {@link PersistenceBackend.commitRepair}. Each backend chooses its own marker + * type: the JSONL backend uses the byte offset to truncate to, the SQLite + * backend uses the seq to delete from (both happen to be `number`). + */ +export interface StoredPrefix { + meta: SessionHeader + events: SessionEvent[] + tornMarker?: TornMarker +} + +/** + * The storage seam between {@link PersistenceCoordinator} and a concrete + * backend: the minimal set of durable primitives the orchestration calls. A + * backend implements these (over files, rows, an object store, …); the + * coordinator supplies everything else (buffering, serialization, cursors, + * adoption, crash repair sequencing, dispose quiescence). + * + * @typeParam TornMarker - the backend's opaque torn-tail repair token (see + * {@link StoredPrefix}). The coordinator treats it as fully opaque. + */ +export interface PersistenceBackend { + /** Human-readable backend name, used in the dispose-failure AggregateError. */ + readonly name: string + + /** + * Read a stored prefix by id, scanning ANY storage scope (for JSONL: every + * cwd bucket). Returns `undefined` if no stored artifact exists. Used by + * resume/load, and — via `!== undefined` — by the create-collision probe. + * The returned `tornMarker` is present iff there is a torn tail to truncate. + */ + loadStored(id: SessionId): Promise | undefined> + + /** + * Read a stored prefix SCOPED to `cwd`. Deliberately distinct from + * {@link loadStored}: HMR live-adoption must only adopt a persisted log at the + * SAME cwd as the live session (a same-id log at a different cwd is a + * collision, not a resume) — conflating the two reintroduces a cross-cwd + * adoption bug. For a globally-unique-id backend (SQLite) `cwd` is ignored. + */ + loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> + + /** + * Durably append a CONTIGUOUS batch, lazily materializing the session first + * when `!isMaterialized`. The materialize-write and the first event batch MUST + * commit ATOMICALLY (a crash between them must not leave a materialized-but- + * empty session). Returns once the batch is durable. + */ + appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise + + /** + * Make a crash repair durable: truncate the torn tail (iff + * `tornMarker !== undefined`) and append `closers` (iff any). NOT required to + * be atomic — a file backend may truncate-then-append in two fsync'd steps. + * Used by load (truncate + synthetic closers) and by live-adoption (truncate + * only, `closers = []`). + */ + commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise + + /** Remove the stored artifact for `id` (the coordinator clears in-memory state). */ + deleteStored(id: SessionId): Promise + + /** List all stored (materialized) sessions' metadata. */ + list(): Promise + + /** + * Optional lifecycle teardown (e.g. close a database handle). Awaited by the + * coordinator's dispose effect AFTER the quiescence drain. A stateless file + * backend omits it. + */ + close?(): Promise +} + +/** Per-session write state held by the coordinator's in-memory bookkeeping. */ +interface SessionState { + meta: SessionHeader + /** The next seq the backend expects to append (the stored log length). */ + cursor: number + /** Whether the session has been physically materialized. */ + materialized: boolean + /** + * The live Session this state was bound to via `onCreated`, if any. State + * created through the public `create()`/`load()` API has no owner; state bound + * to a live session lets `onCreated` reject a second, unrelated session on the + * same id (a collision) instead of silently no-opping. + */ + owner?: Session +} + +/** Collect the rejection reasons from a set of promises (none-throwing). */ +async function settledErrors(promises: Iterable>): Promise { + const settled = await Promise.allSettled([...promises]) + const errors: unknown[] = [] + for (const result of settled) { + if (result.status === 'rejected') errors.push(result.reason) + } + return errors +} + +/** + * Owns the backend-agnostic session write-path orchestration. A backend + * constructs one (`new PersistenceCoordinator(ctx, this)`), implements + * {@link PersistenceBackend}, and delegates its six public service methods to + * the matching coordinator methods. + * + * All per-id operations are serialized (a per-id promise chain) so concurrent + * flushes / a flush racing a load never interleave storage writes. The + * constructor installs the write-path listeners and the dispose effect. + * + * @typeParam TornMarker - the backend's opaque torn-tail repair token. + */ +export class PersistenceCoordinator { + /** Backend bookkeeping keyed by session id (NOT the live Session object). */ + private states = new Map() + /** Write-behind buffers keyed by the live Session (write path). */ + private buffers = new Map() + /** + * Per-session serialization: every operation chains onto the prior one for the + * same id, so writes for one session never interleave. Keyed by session id. + */ + private chains = new Map>() + /** + * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not + * its id: a disposed fiber's session can be replaced by a different live + * Session reusing the same id (HMR, an ACP reconnect), and an id-keyed cache + * would hand the new object the old object's init promise. + * + * Public (readonly) so a backend can expose it for white-box tests that await + * a specific session's init (there is no public API to await one init); the + * coordinator itself only ever mutates it internally. + */ + readonly inits = new Map>() + + constructor(private ctx: Context, private backend: PersistenceBackend) { + this.installWritePath() + } + + // --- public surface (the backend's service methods delegate here) --- + + /** + * Register a new session's metadata (lazy: no physical write until the first + * {@link append}). Rejects if the id is already tracked or already persisted. + */ + create(meta: SessionHeader): Promise { + // Snapshot the metadata at call time: the op runs later (behind the + // per-session chain) and the snapshot is stored as the lazy state, so keeping + // the caller's object by reference would let a later mutation of `id`/`cwd` + // register under one key but materialize under a different path/header. + const snapshot: SessionHeader = { ...meta } + return this.serialize(snapshot.id, () => this.createCore(snapshot)) + } + + private async createCore(meta: SessionHeader): Promise { + // Do NOT clobber an existing session: the SessionId IS the identity. + if (this.states.has(meta.id)) { + throw new Error(`session "${meta.id}" already exists in this backend`) + } + // A persisted artifact under this id (in ANY scope) blocks creation: load/ + // has/resume identify a session by id alone, so a second artifact would make + // resume nondeterministic. + if (await this.backend.loadStored(meta.id) !== undefined) { + throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) + } + // Pure lazy: record intent only. No artifact until the first append. + this.states.set(meta.id, { meta, cursor: 0, materialized: false }) + } + + // `async` so the synchronous validate/clone below reject (not throw) per the + // Promise contract — callers use `await expect(...).rejects`. + /** + * Durably persist a batch of events. Honors the append-only and contiguous-seq + * contracts; rejects non-JSON-serializable `event.data`. + */ + async append(id: SessionId, events: readonly SessionEvent[]): Promise { + // Validate serializability BEFORE cloning so a bad event surfaces the typed + // error rather than an opaque DataCloneError from structuredClone. + assertSerializable(events) + // Deep-snapshot the batch HERE, before the op waits behind the per-session + // chain: a caller that mutates a live array (e.g. session.events) — or an + // event inside it — before the op runs would otherwise have those changes + // persisted. The clone is taken synchronously (at call time). + const batch = events.map(e => structuredClone(e)) + return this.serialize(id, () => this.appendCore(id, batch)) + } + + private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + if (events.length === 0) return + let state = this.states.get(id) + if (state === undefined) state = await this.adopt(id) // calls loadCore, not load + + // Contiguity contract: each event's seq must continue the stored log. + for (const [i, event] of events.entries()) { + if (event.seq !== state.cursor + i) { + throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`) + } + } + + await this.backend.appendBatch(state.meta, events, state.materialized) + // The durable write is the transaction: mark materialized + advance the + // cursor as soon as it commits (uniform across backends). + state.materialized = true + state.cursor += events.length + } + + /** + * Reload a session: its {@link SessionHeader} plus the event log up to the last + * durable checkpoint, with any interrupted final turn durably closed (synthetic + * boundary events) during load. + */ + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.serialize(id, () => this.loadCore(id)) + } + + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const stored = await this.backend.loadStored(id) + if (stored === undefined) throw new Error(`session "${id}" not found`) + const { meta, events, tornMarker } = stored + this.assertVersion(meta) + + // Crash-recovery: if the log ended mid-turn (real, preserved events but no + // closing turn/end), close it durably DURING load so disk, the returned log, + // and the cursor all agree. The interrupted turn's real events are preserved, + // never truncated (a turn can be huge — the session-persistence RFC); only a + // never-fully-written torn tail fragment is discarded. + const closers = interruptedTurnClosers(events) + const balanced = [...events, ...closers] + + // Make the repair durable (truncate the torn tail + append the synthetic + // closers) BEFORE recording state — commitRepair takes `meta` directly, so + // there is no state-path ordering dependency (uniform across backends). + if (tornMarker !== undefined || closers.length > 0) { + await this.backend.commitRepair(meta, tornMarker, closers) + } + // The state keeps its OWN copy of the meta; the returned value is separate so + // a consumer mutating loaded.meta cannot corrupt the backend's metadata. + this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) + return { meta, events: balanced } + } + + // NOTE: there is deliberately no coordinator `list()`. Listing needs none of + // the coordinator's orchestration (no per-id serialization, no cursor, no + // in-memory state) — it is a pure read of stored metadata. A backend's public + // `list()` IS the {@link PersistenceBackend.list} hook (one method); routing it + // through the coordinator would only forward to that same hook, so the + // coordinator stays out of the listing path entirely. + + /** Whether a session is durably present (materialized). */ + async has(id: SessionId): Promise { + const state = this.states.get(id) + if (state?.materialized) return true + // Probe storage scoped to the tracked cwd if known, else any scope. A tracked + // lazy session has a known cwd, so loadLive(id, cwd) hits the exact artifact + // path — a storage fault there (e.g. a non-ENOENT lookup error) must surface, + // not be masked by an any-scope scan that filters a non-directory bucket out. + // For an untracked id `cwd` is undefined, where loadLive scans any scope (= + // loadStored), so this single call covers both. + return (await this.backend.loadLive(id, state?.meta.cwd)) !== undefined + } + + /** Remove a session and all its persisted artifacts. */ + delete(id: SessionId): Promise { + return this.serialize(id, () => this.deleteCore(id)) + } + + private async deleteCore(id: SessionId): Promise { + await this.backend.deleteStored(id) + this.states.delete(id) + } + + // --- per-id serialization + adoption helpers --- + + /** + * Run `op` after any in-flight operation for the same session id, so writes for + * one session never interleave. Errors do not poison the chain. NOTE: serialized + * public methods must NOT call each other (deadlock); they call the unserialized + * `*Core` helpers instead. + */ + private serialize(id: SessionId, op: () => Promise): Promise { + const prior = this.chains.get(id) ?? Promise.resolve() + const next = prior.then(op, op) + // Keep the chain alive but swallow this op's rejection for the NEXT waiter + // (the caller still sees the real rejection via `next`). + this.chains.set(id, next.then(() => undefined, () => undefined)) + return next + } + + /** Build a state for a session discovered in storage but not yet in memory. */ + private async adopt(id: SessionId): Promise { + // loadCore (NOT load) — adopt runs inside an already-serialized op, so + // re-entering the chain via the public load() would deadlock. + await this.loadCore(id) + const state = this.states.get(id) + /* v8 ignore next -- loadCore always sets the state for the id */ + if (!state) throw new Error(`failed to adopt session "${id}"`) + return state + } + + private assertVersion(meta: SessionHeader): void { + if (meta.version !== 1) { + throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) + } + } + + // --- write path (session/event → flush drain) --- + + private installWritePath(): void { + const ctx = this.ctx + + // Capture the header on creation; persist a fork's seed once. Record the init + // promise so flush/dispose can await it (onCreated is async). + ctx.on('session/created', (session) => { void this.initFor(session) }) + + // Snapshot + buffer every event (the live object is mutable; clone so a later + // in-place mutation cannot rewrite a buffered event). Serializability is + // guaranteed at the source (Session.append), so structuredClone is safe. + ctx.on('session/event', (session, event) => { + let buffer = this.buffers.get(session) + if (!buffer) this.buffers.set(session, buffer = []) + buffer.push(structuredClone(event)) + }) + + // Drain to the backend at the durability checkpoint. + ctx.on('session/flush', session => this.flush(session)) + + // Dispose must reach quiescence: await every init + final drain BEFORE + // returning, then close the backend's own resources (AFTER the drain), so no + // write lands after teardown and a close failure never MASKS a drain error. + ctx.effect(() => async () => { + let disposeError: unknown + try { + const errors = [ + ...await settledErrors(this.inits.values()), + ...await settledErrors([...this.buffers.keys()].map(s => this.flush(s))), + ...await settledErrors(this.chains.values()), + ] + if (errors.length > 0) { + throw new AggregateError(errors, `${this.backend.name} dispose failed`) + } + } catch (error: unknown) { + disposeError = error + throw error + } finally { + try { + await this.backend.close?.() + } catch (closeError: unknown) { + // A close failure can only add teardown context; keep the already- + // captured drain AggregateError as the primary failure rather than + // masking it. Only surface the close error if the drain succeeded. + /* v8 ignore start -- close failure racing disposal is a defensive teardown edge */ + if (disposeError === undefined) throw closeError + /* v8 ignore stop */ + } + } + }, `${this.backend.name} write path`) + + // HMR: a hot reload does not replay session/created, so seed existing live + // sessions (mirrors dsh-invariants). + for (const session of ctx.sessions.list()) void this.initFor(session) + } + + /** Start (once) the async init for a session and remember its promise. */ + private initFor(session: Session): Promise { + const existing = this.inits.get(session) + if (existing) return existing + // Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created` + // emit, before any later `append` adds non-seed events. A clone freezes it + // against later mutation of the live event objects. + const seed = session.events.map(e => structuredClone(e)) + const p = this.onCreated(session, seed) + // Attach a no-op rejection handler so a failing init does not surface as an + // unhandled rejection if no flush observes `p` before it rejects. The REAL + // error is still delivered: flush/dispose await the same `p` from the map. + p.catch(() => { /* observed by flush/dispose via the stored promise */ }) + this.inits.set(session, p) + return p + } + + /** + * Whether a live session's `seed` reproduces the first `cursor` persisted + * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when + * a live session claims ownerless state left by a prior `load()`/`create()`. + */ + private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise { + if (cursor === 0) return true + const stored = await this.backend.loadStored(id) + /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ + if (stored === undefined) return false + return seedCoversPrefix(seed, stored.events.slice(0, cursor)) + } + + /** + * On session/created: sync the backend's in-memory state to a live Session. + * + * Cases, by whether this backend tracks the id and whether an artifact exists: + * 1. Already tracked → no-op (or claim ownerless state if the seed matches, + * or reclaim a truly-abandoned id, else reject as a collision). + * 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX + * of the live events → ADOPT it (HMR/reload), persisting any live suffix. + * 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision). + * 4. Not tracked and NO artifact → a genuinely new session: register meta + * (lazy) and persist its seed once. + */ + private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise { + const id = session.header.id + const tracked = this.states.get(id) + if (tracked !== undefined) { + // case 1: already tracked. + /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */ + if (tracked.owner === session) return + if (tracked.owner === undefined) { + // Ownerless state from the public create()/load() API. The FIRST live + // session claims it — but ONLY if its seed reproduces the persisted + // prefix (else a fresh, unrelated session reusing the id would have its + // seq 0..cursor-1 events filtered as already-written and grafted on). + if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { + throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) + } + tracked.owner = session + // Persist the seed SUFFIX beyond the persisted prefix. Constructor seed + // events never emit session/event, so the buffer never sees them. + const suffix = seed.slice(tracked.cursor) + if (suffix.length > 0) await this.append(id, suffix) + return + } + // Owned by a DIFFERENT live session. Reclaim ONLY a truly-abandoned id + // (never materialized, no pending buffer); else it is a real collision. + const ownerBuffer = this.buffers.get(tracked.owner) + if (!tracked.materialized && !ownerBuffer?.length) { + this.states.delete(id) + } else { + throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) + } + } + + // case 2/3: an artifact at THIS cwd is adopted as a live prefix (or rejected + // as a collision inside adoptLivePrefix). cwd-scoped (loadLive), never + // any-scope: a same-id artifact at a different cwd is a collision, not a + // resume. + const live = await this.backend.loadLive(id, session.header.cwd) + if (live !== undefined) { + // Do NOT route through loadCore(): that crash-repairs open turns as + // interrupted, which is wrong for HMR while the live Session is still the + // authority and may append the real step/turn end later. + await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) + return + } + + // case 4: a genuinely new session. Register its meta (lazy), then persist its + // seed (events present at creation time) once. + const meta: SessionHeader = { ...session.header } + await this.create(meta) + // Bind this state to the live session so a later DIFFERENT session reusing + // the id is detected as a collision (case 1) rather than silently no-opped. + const created = this.states.get(id) + /* v8 ignore next -- create() always sets the state for the id */ + if (created !== undefined) created.owner = session + if (seed.length > 0) await this.append(id, seed) + } + + /** + * Adopt a stored prefix as a live session's history (HMR/reload): verify the + * seed covers the stored prefix, truncate any torn tail (NOT the open turn — + * the live Session is still the authority), bind ownership, and persist the + * live suffix that was ahead of the stored prefix. + */ + private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { + const { meta, events, tornMarker } = stored + this.assertVersion(meta) + if (!seedCoversPrefix(seed, events)) { + throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) + } + // Truncate-only repair (no closers): the open turn is NOT closed here. + if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) + this.states.set(session.header.id, { + meta: { ...meta }, + cursor: events.length, + materialized: true, + owner: session, + }) + const suffix = seed.slice(events.length) + if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + } + + private async flush(session: Session): Promise { + // Wait for the session's init (onCreated) so the state/cursor and any + // fork-seed persistence are in place before draining. Awaiting the same + // promise initFor stored also surfaces an init failure (e.g. a collision) + // here, where the caller of session/flush observes it. + await this.inits.get(session) + // Serialize the WHOLE drain (read cursor → append → splice) on the per-session + // chain so two concurrent flushes cannot both read the same cursor and + // seq-mismatch on the second append. + await this.serialize(session.header.id, () => this.drain(session)) + } + + /** Drain a session's write buffer to the backend. Caller serializes this per id. */ + private async drain(session: Session): Promise { + const buffer = this.buffers.get(session) + if (!buffer?.length) return + // Copy WITHOUT removing: the buffer is the only durable-pending copy of these + // events. Drain it only AFTER the append commits; events pushed during the + // await sit past batch.length and survive the prefix splice, so a + // retry/dispose re-drains the rest. + const batch = buffer.slice() + const state = this.states.get(session.header.id) + // Only append events at or beyond the write cursor (a resumed session's seed + // is already stored). flush awaits the init above, which always sets state, + // so the `?? 0` fallback is a defensive guard that never fires in practice. + /* v8 ignore next -- state is always set by the awaited init before flush */ + const cursor = state?.cursor ?? 0 + const fresh = batch.filter(e => e.seq >= cursor) + // appendCore (NOT the serialized append) — drain already runs inside the + // per-session chain, so re-entering via append() would deadlock. + if (fresh.length > 0) await this.appendCore(session.header.id, fresh) + buffer.splice(0, batch.length) + } +} diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/src/index.ts index 48402b54a7..8ff9aa8cb2 100644 --- a/packages/session-persistence/src/index.ts +++ b/packages/session-persistence/src/index.ts @@ -28,6 +28,10 @@ import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-se // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' +// The backend-agnostic write-path orchestration first-party backends compose. +export { PersistenceCoordinator } from './coordinator.ts' +export type { PersistenceBackend, StoredPrefix } from './coordinator.ts' + declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence diff --git a/packages/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/tests/coordinator-contract.ts new file mode 100644 index 0000000000..24a8f4c0cf --- /dev/null +++ b/packages/session-persistence/tests/coordinator-contract.ts @@ -0,0 +1,734 @@ +/** + * Reusable ORCHESTRATION suite for any backend that composes a + * {@link PersistenceCoordinator}. Where {@link runPersistenceContract} (in + * contract.ts) pins the public read/write SEMANTICS, this suite pins the + * coordinator's WRITE-PATH ORCHESTRATION — the behavior that is identical across + * every first-party backend because it lives in the shared coordinator, not in + * the storage primitives: the `session/created` → `session/event` → + * `session/flush` → dispose drain, lazy materialization, fork-seed persistence, + * the four `onCreated` adoption cases (new / HMR-adopt / collision / + * ownerless-claim), crash-tail repair on load, and dispose-time quiescence. + * + * A backend imports {@link runCoordinatorContract} and calls it with a + * {@link CoordinatorFixture} factory that knows how to (a) mount the REAL + * backend plugin on a {@link Context} over a SHARED storage scope (so HMR/reload + * tests can dispose one instance and mount another over the same bytes/rows), + * and (b) inject a never-committed torn tail for one session + * ({@link CoordinatorFixture.corruptTail}) so the through-coordinator torn-tail + * repair branch is exercised against real storage. The suite drives everything + * through the PUBLIC {@link SessionPersistence} API + the cordis SessionStore + * write path — never the storage primitives directly — so it runs unchanged for + * every backend (memory / jsonl / sqlite). + * + * Each scenario here was previously DUPLICATED in `jsonl.spec.ts` and + * `sqlite.spec.ts`; it now lives once and runs once per backend through the + * fixture. The per-backend specs keep ONLY their storage-mechanics tests. + * + * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract + */ + +import { describe, expect, it } from 'vitest' +import { Context, type Fiber } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '../src/index.ts' +import { meta, oneTurnLog } from './contract.ts' + +/** + * The backend-specific capabilities the orchestration suite needs beyond the + * public service API. A fresh fixture is created per test (isolated storage); + * the suite mounts/disposes backend instances on it and cleans it up at the end. + */ +export interface CoordinatorFixture { + /** + * Mount the REAL backend plugin (via `ctx.plugin`, the Loader path) on `ctx`, + * over THIS fixture's shared storage scope. Returns the plugin fiber so the + * suite can dispose a single instance (HMR/reload) while the storage — and any + * still-live session in another fiber — survives. The caller has already + * mounted `SessionStore` on `ctx`. + */ + mount: (ctx: Context) => Promise + + /** + * Inject a NEVER-COMMITTED torn tail into the backend's storage for `id` at + * the given `cwd` (the cwd the session was created with): a half-written + * record past the committed region (JSONL: a partial line with no newline; + * SQLite: a row with invalid `data` JSON past the committed seq). This drives + * the coordinator's `loadCore` `tornMarker !== undefined` → `commitRepair` + * branch against real storage. + * + * OMITTED by a backend that structurally has no torn tails (memory): the + * torn-tail scenario then self-skips (asserted explicitly in the suite). + */ + corruptTail?: (id: SessionId, cwd: string | undefined) => Promise + + /** Tear down the storage scope (remove the temp dir / file). */ + cleanup: () => Promise +} + +/** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */ +const WORK = '/w' + +/** The per-session init map a backend exposes for white-box init awaits. */ +function inits(persistence: SessionPersistence): Map> { + return (persistence as unknown as { inits: Map> }).inits +} + +/** Append a whole event log to a live session, event by event (drives session/event). */ +function send(session: Session, events: readonly SessionEvent[]): void { + for (const e of events) session.append(e.type, e.data) +} + +/** A live session created inside its OWN fiber, so it survives a backend reload. */ +async function liveSessionInFiber( + ctx: Context, id: string, cwd: string | undefined, +): Promise { + let session!: Session + await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(id, cwd !== undefined ? { meta: { cwd } } : undefined) + }, { inject: ['sessions'] })) + return session +} + +/** + * Run the coordinator orchestration suite against a backend. `makeFixture()` + * MUST return a fresh fixture (isolated storage) each call. + */ +export function runCoordinatorContract(name: string, makeFixture: () => Promise): void { + describe(`PersistenceCoordinator orchestration: ${name}`, () => { + /** Mount SessionStore + a backend instance on a fresh context over the fixture's storage. */ + async function freshCtx(fix: CoordinatorFixture): Promise<{ ctx: Context; fiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await fix.mount(ctx) + return { ctx, fiber } + } + + // --- write path: live session → flush → reload --- + + it('persists a live session driven through the store, surviving reload', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create('live', { meta: { cwd: WORK } }) + send(session, oneTurnLog()) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('live')) + expect(loaded.events).toHaveLength(6) + expect(loaded.meta.cwd).toBe(WORK) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create('mutate', { meta: { cwd: WORK } }) + const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) + // Mutate the live event object AFTER it was buffered by session/event. + ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('mutate')) + const first = loaded.events[0] + expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original') + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('append snapshots the batch: mutating the caller array/events after the call is ignored', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = meta('snapshot', WORK) + await ctx.sessionPersistence.create(m) + const events = oneTurnLog() // seqs 0..5 + const userMsg = events[1] // the user/message event + const p = ctx.sessionPersistence.append(m.id, events) + // Mutate the caller's array AND an event object after the call but before + // the queued op runs: the snapshot taken at call time must shield the copy. + events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }) + if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'MUTATED' }] + await p + const loaded = await ctx.sessionPersistence.load(m.id) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6 + const persisted = JSON.stringify(loaded.events) + expect(persisted).toContain('hi') // original content + expect(persisted).not.toContain('MUTATED') + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + // --- fork / resume --- + + it('fork: a seeded new session persists its seed once (no double-write on a no-op flush)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const seed = oneTurnLog() + // A fork: a brand-new id whose seed came from elsewhere. + const forked = ctx.sessions.create('forked', { seed, meta: { cwd: WORK } }) + await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed + const loaded = await ctx.sessionPersistence.load(SessionId('forked')) + expect(loaded.events).toEqual(seed) + // A flush with no NEW events must not double-write. + await ctx.parallel('session/flush', forked) + const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) + expect(reloaded.events).toEqual(seed) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => { + const fix = await makeFixture() + const first = await freshCtx(fix) + try { + // First lifecycle: persist a session through the store. + const s1 = first.ctx.sessions.create('resumed', { meta: { cwd: WORK } }) + send(s1, oneTurnLog()) + await first.ctx.parallel('session/flush', s1) + } finally { + await first.fiber.dispose() + } + + // Second lifecycle: a NEW backend instance + a session re-created with the + // same id SEEDED with the loaded events. onCreated adopts the stored log + // (does not re-persist the seed); a new turn appends at seq 6. + const second = await freshCtx(fix) + try { + const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) + const s2 = second.ctx.sessions.create('resumed', { seed: loaded.events, meta: { cwd: WORK } }) + await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt + s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await second.ctx.parallel('session/flush', s2) + + const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) + // 6 original + 2 new, contiguous, no duplicated seed. + expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + + // --- HMR --- + + it('HMR: applying the plugin seeds existing live sessions', async () => { + const fix = await makeFixture() + const ctx = new Context() + await ctx.plugin(SessionStore) + // A session exists BEFORE the persistence plugin is applied. + const session = ctx.sessions.create('pre-existing', { meta: { cwd: WORK } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const fiber = await fix.mount(ctx) + try { + // The plugin seeded it on apply; a subsequent flush persists its events. + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing')) + expect(loaded.events.length).toBeGreaterThanOrEqual(2) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('HMR: dispose drains remaining buffers', async () => { + const fix = await makeFixture() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await fix.mount(ctx) + const session = await liveSessionInFiber(ctx, 'drain', WORK) + session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // No explicit flush — dispose must drain. + await fiber.dispose() + + // A fresh backend instance reads what the disposed one drained. + const second = await freshCtx(fix) + try { + const loaded = await second.ctx.sessionPersistence.load(SessionId('drain')) + expect(loaded.events.length).toBeGreaterThanOrEqual(2) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + + it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => { + const fix = await makeFixture() + const ctx = new Context() + await ctx.plugin(SessionStore) + // The session lives in its OWN fiber so it survives the backend reload. + const session = await liveSessionInFiber(ctx, 'hmr-adopt', WORK) + try { + // Backend instance 1 materializes the session. + const backend1 = await fix.mount(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + + // Hot-reload: dispose instance 1, mount instance 2 over the SAME storage + // while the session stays live. Instance 2 has an empty states map but the + // log is materialized and is a prefix of the live events — it must ADOPT + // (not reject). A second turn appended after reload then persists. + await backend1.dispose() + await fix.mount(ctx) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow() + + const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt')) + expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + + it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => { + const fix = await makeFixture() + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = await liveSessionInFiber(ctx, 'hmr-suffix', WORK) + try { + // Instance 1 flushes turn 1. + const backend1 = await fix.mount(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + + // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT + // flushing turn 2: it is now ONLY in the live session's events; the new + // backend never buffered it via session/event. + await backend1.dispose() + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + + // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the + // live suffix (turn 2) carried in the session's events. + await fix.mount(ctx) + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3]) + expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + + it('HMR adoption does NOT crash-repair an active open turn as interrupted (truncate without closers)', async () => { + const fix = await makeFixture() + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = await liveSessionInFiber(ctx, 'hmr-open', WORK) + try { + const first = await fix.mount(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + await ctx.parallel('session/flush', session) + + // Crash-tail a torn fragment past the (open) committed turn, then reload. + await first.dispose() + if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) + const second = await fix.mount(ctx) + // The live session is still the authority: it appends the REAL step/turn + // end. Adoption must truncate the torn tail but NOT synthesize closers. + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) + expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) + expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + await second.dispose() + } finally { + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + + // --- collision / id reuse --- + + it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { + const fix = await makeFixture() + const first = await freshCtx(fix) + try { + const s1 = first.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + send(s1, oneTurnLog()) + await first.ctx.parallel('session/flush', s1) + } finally { + await first.fiber.dispose() + } + + // A FRESH backend + a NEW live session with the same id but NO explicit + // resume. onCreated treats it as new; create() rejects because a log already + // exists. The rejection surfaces via the init promise (flush awaits it). + const second = await freshCtx(fix) + try { + const s2 = second.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await expect(inits(second.ctx.sessionPersistence).get(s2)) + .rejects.toThrow(/already has a persisted log|id collision/) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + + it('an abandoned lazy session (never materialized) releases its id for reuse', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // A live session created then disposed BEFORE its first append: cursor 0, + // never materialized. A new live session reusing the id must reclaim it. + let firstSession!: Session + const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { + firstSession = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state + await firstFiber.dispose() // disposed before any append → never materialized + + let reuse!: Session + await ctx.plugin(Object.assign((inner: Context) => { + reuse = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined() + reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', reuse) + const loaded = await ctx.sessionPersistence.load(SessionId('abandoned')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1]) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + let first!: Session + const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { + first = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + await inits(ctx.sessionPersistence).get(first) + // Append a turn but do NOT flush — events sit in the write-behind buffer. + first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await firstFiber.dispose() // disposed before flush; not materialized, buffer pending + + let reuse!: Session + await ctx.plugin(Object.assign((inner: Context) => { + reuse = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create('idem', { meta: { cwd: WORK } }) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + // Re-emit session/created for the SAME live session (idempotent initFor). + ctx.emit('session/created', session) + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(SessionId('idem')) + expect(loaded.events).toHaveLength(2) // not doubled + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + // --- ownerless-state claim (public create()/load() then a live session arrives) --- + + it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // create() registers ownerless state with cursor 0 (lazy, nothing persisted). + await ctx.sessionPersistence.create(meta('lazy-claim', WORK)) + // A live session with that id arrives and claims it (cursor 0 matches + // trivially), persisting its seed. + const live = ctx.sessions.create('lazy-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined() + const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Materialize a log, then load() it WITHOUT a live session — ownerless + // state, cursor at the persisted length. + await ctx.sessionPersistence.create(meta('preview', WORK)) + await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog()) + await ctx.sessionPersistence.load(SessionId('preview')) + + // A FRESH (empty-seed) live session reusing that id must be rejected: its + // seq 0..cursor-1 events would otherwise be filtered as already-persisted. + let fresh!: Session + await ctx.plugin(Object.assign((inner: Context) => { + fresh = inner.sessions.create('preview', { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + await expect(inits(ctx.sessionPersistence).get(fresh)) + .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Materialize and load (ownerless, cursor = 6). + await ctx.sessionPersistence.create(meta('claim', WORK)) + await ctx.sessionPersistence.append(SessionId('claim'), oneTurnLog()) + const { events } = await ctx.sessionPersistence.load(SessionId('claim')) + + // A live session SEEDED with the loaded log PLUS a new turn claims the + // ownerless state and persists only the suffix. + const cont = ctx.sessions.create('claim', { seed: [ + ...events, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ], meta: { cwd: WORK } }) + await inits(ctx.sessionPersistence).get(cont) + const loaded = await ctx.sessionPersistence.load(SessionId('claim')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + // --- append adopts a storage-only session (fresh instance, no prior create/load) --- + + it('append adopts a storage-only session (fresh instance) and continues the seq', async () => { + const fix = await makeFixture() + const first = await freshCtx(fix) + try { + const m = meta('adopt-append', WORK) + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + } finally { + await first.fiber.dispose() + } + + // A fresh instance appends a second turn WITHOUT a prior create/load: append + // must adopt the stored session (cursor = stored length) and continue. + const second = await freshCtx(fix) + try { + await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ]) + const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + + // --- small public-API edges that the coordinator owns uniformly --- + + it('append of an empty batch is a no-op (stays lazy)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = meta('empty-batch', WORK) + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, []) + expect(await ctx.sessionPersistence.has(m.id)).toBe(false) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('load rejects a missing session', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('delete of a non-existent session is a no-op', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined() + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('create rejects a duplicate id (in memory and on a persisted log)', async () => { + const fix = await makeFixture() + const first = await freshCtx(fix) + try { + const m = meta('dup', WORK) + await first.ctx.sessionPersistence.create(m) + // Same in-memory state. + await expect(first.ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists in this backend/) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + } finally { + await first.fiber.dispose() + } + + // A fresh instance over the same storage sees the persisted log. + const second = await freshCtx(fix) + try { + await expect(second.ctx.sessionPersistence.create(meta('dup', WORK))) + .rejects.toThrow(/already has a persisted log on disk/) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an unknown format version on load (assertVersion)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = { version: 2, id: SessionId('v2'), createdAt: 1, cwd: WORK } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('round-trips a header with parentSession (fork lineage)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = { version: 1, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const loaded = await ctx.sessionPersistence.load(m.id) + expect(loaded.meta.parentSession).toBe('the-parent') + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('flush before init resolves uses cursor 0', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Append directly to a live session and flush IMMEDIATELY, before the + // async onCreated init has necessarily set state (exercises the + // state-undefined cursor path). + const session = ctx.sessions.create('flush-nostate', { meta: { cwd: WORK } }) + session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate')) + expect(loaded.events).toHaveLength(2) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + // --- crash-tail repair THROUGH the coordinator (real storage torn tail) --- + + it('torn-tail load: a never-committed tail is truncated and the open turn closed during load (commitRepair w/ tornMarker)', async () => { + const fix = await makeFixture() + if (!fix.corruptTail) { + // A memory-style store has no torn tails (every write is atomic in RAM), + // so there is no tornMarker path to exercise. Assert that explicitly + // instead of silently skipping, then bail. + expect(fix.corruptTail).toBeUndefined() + await fix.cleanup() + return + } + const first = await freshCtx(fix) + try { + const m = meta('torn', WORK) + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed 0..5 (balanced) + // A second turn whose real events are durable but never closed (open turn). + await first.ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + ]) + } finally { + await first.fiber.dispose() + } + // Inject a torn fragment past the committed region (never-committed tail). + await fix.corruptTail(SessionId('torn'), WORK) + + // A FRESH instance loads: the torn tail is truncated (tornMarker !== + // undefined) AND the open turn 2 is closed with synthetic step/end + + // turn/end {interrupted} — commitRepair runs with BOTH a torn marker and + // closers. The preserved real events (0..7) are never truncated. + const second = await freshCtx(fix) + try { + const loaded = await second.ctx.sessionPersistence.load(SessionId('torn')) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(loaded.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 + 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real + synthetic closers + ]) + const last = loaded.events.at(-1)! + expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' }) + + // The repair is durable: the next append continues at the balanced length + // (seq 10) and a reload round-trips identically. + await second.ctx.sessionPersistence.append(SessionId('torn'), [ + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, + ]) + const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn')) + expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + } finally { + await second.fiber.dispose() + await fix.cleanup() + } + }) + }) +} diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/tests/persistence.spec.ts index 04ac108ed3..8b5a437735 100644 --- a/packages/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/tests/persistence.spec.ts @@ -1,76 +1,132 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { SessionId, isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import { SessionPersistence, assertSerializable, seedCoversPrefix } from '../src/index.ts' +import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { + SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix, + type PersistenceBackend, type StoredPrefix, +} from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' + +/** The durable store shape: materialized sessions only (no lazy entries). */ +type MemoryStore = Map + +/** Optional plugin config: an EXTERNAL store shared across backend instances. */ +interface MemoryConfig { store?: MemoryStore } /** - * A minimal in-memory {@link SessionPersistence} used to (a) cover the abstract - * base's constructor + service registration and (b) validate the reusable - * contract suite itself. The real durable backend is - * `@deepseek-ai/dsh-session-persistence-jsonl`. + * A trivial in-memory {@link SessionPersistence} that composes a + * {@link PersistenceCoordinator} over a dependency-free `Map`-backed + * {@link PersistenceBackend}. It is BOTH the coordinator's reference vehicle + * (the simplest possible storage — a `Map` with no torn + * tails, so `tornMarker` is always undefined) and the cover for the abstract + * base's constructor + service registration. The real durable backends are + * `@deepseek-ai/dsh-session-persistence-jsonl` / `-sqlite`. + * + * The store can be supplied via config so two backend instances share one Map — + * the in-RAM analogue of two backends over the same file/db, which the + * coordinator orchestration suite's HMR/reload tests need (a fresh instance with + * an empty in-memory states map adopting an already-materialized session). */ -class MemoryPersistence extends SessionPersistence { - private store = new Map() - private pending = new Map() +class MemoryPersistence extends SessionPersistence implements PersistenceBackend { + static inject = ['sessions'] - async create(m: SessionHeader): Promise { - // Lazy: record the intended meta, but stay absent from has/list until the - // first append materializes the session. - this.pending.set(m.id, m) + override readonly name = 'session-persistence-memory' + + /** The whole durable store: materialized sessions only (no lazy entries). */ + private store: MemoryStore + private coordinator: PersistenceCoordinator + + constructor(ctx: Context, config?: MemoryConfig) { + super(ctx) + // Assign the store BEFORE constructing the coordinator: the coordinator's + // constructor installs the write path and synchronously seeds existing live + // sessions (onCreated → loadLive → this.store), so store must exist first. + this.store = config?.store ?? new Map() + this.coordinator = new PersistenceCoordinator(this.ctx, this) } - async append(id: SessionId, events: readonly SessionEvent[]): Promise { - const existing = this.store.get(id) - const nextSeq = existing ? existing.events.length : 0 - if (events.length > 0 && events[0]!.seq !== nextSeq) { - throw new Error(`append seq mismatch for "${id}": expected ${nextSeq}, got ${events[0]!.seq}`) - } - for (let i = 0; i < events.length; i++) { - const e = events[i]! - if (e.seq !== nextSeq + i) throw new Error(`non-contiguous seq in batch for "${id}" at index ${i}`) - if (!isJsonValue(e.data)) { - throw new Error(`event "${e.type}" carries non-JSON-serializable data`) - } + // --- service surface (delegated to the coordinator) --- + + create(m: SessionHeader): Promise { + return this.coordinator.create(m) + } + + append(id: SessionId, events: readonly SessionEvent[]): Promise { + return this.coordinator.append(id, events) + } + + load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.load(id) + } + + has(id: SessionId): Promise { + return this.coordinator.has(id) + } + + delete(id: SessionId): Promise { + return this.coordinator.delete(id) + } + + /** White-box accessor: await a specific session's onCreated init. */ + get inits(): Map> { + return this.coordinator.inits + } + + // --- PersistenceBackend hooks (the Map storage primitives) --- + + // A Map-backed store has no torn tails, so `tornMarker` is never set. Ids are + // globally unique, so loadStored and loadLive are identical (cwd is ignored). + async loadStored(id: SessionId): Promise | undefined> { + const entry = this.store.get(id) + if (!entry) return undefined + return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } + } + + loadLive(id: SessionId, _cwd: string | undefined): Promise | undefined> { + return this.loadStored(id) + } + + async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + // Defense-in-depth: the coordinator already validates serializability, but a + // durable store must reject non-JSON data at its own boundary too. + for (const e of events) { + if (!isJsonValue(e.data)) throw new Error(`event "${e.type}" carries non-JSON-serializable data`) } + const existing = this.store.get(m.id) if (!existing) { - const m = this.pending.get(id) - if (!m) throw new Error(`append before create for "${id}"`) - this.store.set(id, { meta: m, events: structuredClone(events) as SessionEvent[] }) + // First batch: `_isMaterialized` is false (the coordinator only omits + // materialization on the first batch); writing the entry IS the materialization. + this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] }) } else { existing.events.push(...structuredClone(events) as SessionEvent[]) } } - async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const entry = this.store.get(id) - if (!entry) throw new Error(`session "${id}" not found`) - // Honor the crash-recovery contract: if the stored log ends mid-turn, close - // the orphaned turn durably with synthetic boundary events and continue from - // the balanced length. - const closers = interruptedTurnClosers(entry.events) - if (closers.length > 0) entry.events.push(...structuredClone(closers)) - return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } + async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise { + // No torn tails in a Map store, so `_tornMarker` is always undefined; only the + // synthetic closers are appended (the same DELETE+INSERT a DB backend does, + // minus the truncate). + const entry = this.store.get(m.id) + /* v8 ignore next -- commitRepair only runs for a materialized (stored) session */ + if (!entry) return + if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) + } + + async deleteStored(id: SessionId): Promise { + this.store.delete(id) } async list(): Promise { return [...this.store.values()].map(e => structuredClone(e.meta)) } - - async has(id: SessionId): Promise { - return this.store.has(id) - } - - async delete(id: SessionId): Promise { - this.store.delete(id) - this.pending.delete(id) - } } // Run the shared contract against the in-memory backend. runPersistenceContract('memory', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) const fiber = await ctx.plugin(MemoryPersistence) return { persistence: ctx.sessionPersistence, @@ -78,9 +134,24 @@ runPersistenceContract('memory', async () => { } }) +// Run the shared coordinator orchestration suite against the in-memory backend. +// A per-fixture Map is the shared "storage", so two mounted instances see the +// same materialized sessions (HMR/reload). `corruptTail` is OMITTED: a Map store +// writes atomically in RAM and has no torn tails, so the suite's torn-tail test +// self-skips (and asserts the omission). The real torn-tail repair branch is +// covered by the jsonl/sqlite fixtures, which CAN inject one. +runCoordinatorContract('memory', async (): Promise => { + const store: MemoryStore = new Map() + return { + mount: async ctx => ctx.plugin(MemoryPersistence, { store }), + cleanup: async () => { store.clear() }, + } +}) + describe('SessionPersistence service registration', () => { it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) const fiber = await ctx.plugin(MemoryPersistence) expect(ctx.sessionPersistence).toBeInstanceOf(SessionPersistence) @@ -90,6 +161,7 @@ describe('SessionPersistence service registration', () => { it('round-trips through the registered service instance', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) const fiber = await ctx.plugin(MemoryPersistence) const m = meta('reg') await ctx.sessionPersistence.create(m) From 3d67a982919bb18f1a8b860d1dc4291cfa36a728 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 04:13:37 +0800 Subject: [PATCH 04/87] fix(session-persistence-jsonl): make loadLive cwd-scope-exact (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's converge pass on PR B found a cross-cwd adoption hole: the coordinator calls loadLive(id, session.header.cwd) for HMR live-adoption, but JSONL's loadLive delegated to findLog(id, cwd) which, for cwd === undefined, scanned ALL cwd buckets. So a live NO-CWD session could adopt a same-id log from a real cwd bucket, ending with a live cwd: undefined but a persisted meta.cwd: '/w'. loadLive must treat `undefined` as the DEFINITE no-cwd bucket, not "unknown": it now goes straight to logPath(cwd, id) (which maps undefined -> _no-cwd), never the all-buckets scan. loadStored/deleteStored keep the any-cwd scan (resume/removal identify by id alone), so findLog is now a pure scan-all and loses its dead cwd-direct branch. The coordinator's has() relied on loadLive(id, undefined) meaning "any scope" for an untracked id — fixed to use loadStored for the untracked (unknown-cwd) case and loadLive only for a tracked session's known cwd. Adds a regression test: a no-cwd live session reusing an id persisted in a real cwd bucket no longer cross-cwd-adopts — it falls through to createCore's any-cwd collision probe and REJECTS, leaving the original log untouched. Also fixes the README to say `tornMarker !== undefined` (a marker may be falsy, 0). --- .../session-persistence-jsonl/src/index.ts | 46 +++++++++++-------- .../tests/jsonl.spec.ts | 37 ++++++++++++++- packages/session-persistence/README.md | 2 +- .../session-persistence/src/coordinator.ts | 16 ++++--- 4 files changed, 73 insertions(+), 28 deletions(-) diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index a469095c5d..76df3f3ccb 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -127,24 +127,32 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Read a stored prefix by id across ALL cwd buckets (cwd unknown). */ async loadStored(id: SessionId): Promise | undefined> { - return this.readPrefix(id, undefined) - } - - /** Read a stored prefix SCOPED to `cwd` (HMR live-adoption must not cross cwd). */ - async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { - return this.readPrefix(id, cwd) + const file = await this.findLog(id) + if (file === undefined) return undefined + return this.readPrefix(file.path) } /** - * Read and scan a session's log into a {@link StoredPrefix}. Folds the + * Read a stored prefix SCOPED to `cwd` (HMR live-adoption must not cross cwd). + * `undefined` is the DEFINITE "no-cwd" bucket, NOT "unknown" — a live session + * with no cwd may only adopt a persisted no-cwd log, never a same-id log that + * lives in some other cwd bucket. So this looks at exactly `logPath(cwd)` + * (which maps `undefined` → the `_no-cwd` bucket), never the all-buckets scan. + */ + async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { + const path = logPath(this.root, cwd, id) + if (!await this.exists(path)) return undefined + return this.readPrefix(path) + } + + /** + * Read and scan a session's log file into a {@link StoredPrefix}. Folds the * torn-tail comparison HERE so the `tornMarker` is the byte offset to truncate * to (or `undefined` when nothing is torn) — the coordinator never sees the * raw byteLength. */ - private async readPrefix(id: SessionId, cwd: string | undefined): Promise | undefined> { - const file = await this.findLog(id, cwd) - if (file === undefined) return undefined - const buffer = await readFile(file.path) + private async readPrefix(path: string): Promise> { + const buffer = await readFile(path) const { meta, events, committedBytes } = scanLog(buffer) return { meta, @@ -174,7 +182,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Remove a session's log file (the coordinator clears its in-memory state). */ async deleteStored(id: SessionId): Promise { - const file = await this.findLog(id, undefined) + const file = await this.findLog(id) if (file) await rm(file.path, { force: true }) } @@ -331,13 +339,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Find a session's log file across cwd buckets (when cwd is unknown). */ - private async findLog(id: SessionId, cwd: string | undefined): Promise<{ path: string; cwd: string | undefined } | undefined> { - if (cwd !== undefined) { - const path = logPath(this.root, cwd, id) - return (await this.exists(path)) ? { path, cwd } : undefined - } - // Unknown cwd: scan buckets for a matching file name. + /** + * Find a session's log file by id across ALL cwd buckets — the any-cwd scan + * for `loadStored`/`deleteStored` (resume and removal identify a session by id + * alone). The cwd-scoped lookup (`loadLive`) does NOT use this; it goes + * straight to `logPath(cwd)` so a no-cwd session can't match a real-cwd bucket. + */ + private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { const target = encodeSegment(id) + '.jsonl' for (const dir of await this.listCwdDirs()) { const path = `${dir}/${target}` diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence-jsonl/tests/jsonl.spec.ts index b48bb249d1..d36723f396 100644 --- a/packages/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence-jsonl/tests/jsonl.spec.ts @@ -484,6 +484,41 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) + it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => { + // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then + // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map — + // the HMR/reload path where onCreated goes through loadLive, not a tracked + // collision). + await ctx.sessionPersistence.create(meta('x', '/w')) + await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog()) + await ctx.fiber.dispose() + + // Backend 2 over the SAME root. A live no-cwd session reuses id "x". Because + // loadLive(id, undefined) is the DEFINITE no-cwd bucket (NOT an all-buckets + // scan), case-2 adoption does NOT match the "/w" log — so it would NOT + // silently graft the no-cwd events onto the "/w" log with a mismatched cwd + // (the bug a non-scope-exact loadLive caused). It falls through to the + // new-session path, where createCore's any-cwd collision probe (loadStored) + // catches the duplicate id and REJECTS — the id is taken in another bucket. + const ctx2 = new Context() + await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionPersistenceJsonl, { root }) + const backend = ctx2.sessionPersistence as unknown as { inits: Map> } + let b!: Session + await ctx2.plugin(Object.assign((inner: Context) => { + b = inner.sessions.create('x') // no cwd + }, { inject: ['sessions'] })) + await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/) + + // The "/w" log is untouched — no no-cwd events were grafted onto it, and no + // `_no-cwd` log for "x" was created. + const inW = scanLog(await readFile(logPath(root, '/w', SessionId('x')))) + expect(inW.meta.cwd).toBe('/w') + expect(inW.events).toHaveLength(6) + await expect(stat(logPath(root, undefined, SessionId('x')))).rejects.toThrow() + await ctx2.fiber.dispose() + }) + it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => { // Materialize and load (ownerless, cursor = 6). await ctx.sessionPersistence.create(meta('divergent', '/a')) @@ -549,7 +584,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // open() must surface, not be collapsed to "not found" (which would let a // collision check proceed under a false absence assumption). A LAZY session // (created, never appended) keeps its cwd in state, so has() reaches - // findLog(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a + // loadLive(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a // regular file: open()ing `bucket/.jsonl` under it then fails ENOTDIR. const cwd = '/x' const ctx2 = new Context() diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index a63ea6284a..3ec9f7860b 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -35,7 +35,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. | | `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | -| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | +| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/src/coordinator.ts index 09bc6eb943..f35685c677 100644 --- a/packages/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/src/coordinator.ts @@ -287,13 +287,15 @@ export class PersistenceCoordinator { async has(id: SessionId): Promise { const state = this.states.get(id) if (state?.materialized) return true - // Probe storage scoped to the tracked cwd if known, else any scope. A tracked - // lazy session has a known cwd, so loadLive(id, cwd) hits the exact artifact - // path — a storage fault there (e.g. a non-ENOENT lookup error) must surface, - // not be masked by an any-scope scan that filters a non-directory bucket out. - // For an untracked id `cwd` is undefined, where loadLive scans any scope (= - // loadStored), so this single call covers both. - return (await this.backend.loadLive(id, state?.meta.cwd)) !== undefined + // A TRACKED lazy session has a known cwd: probe that exact bucket via + // loadLive(id, cwd) — including the no-cwd bucket when its cwd is undefined. + // An UNTRACKED id has a genuinely UNKNOWN cwd, so it must scan ANY scope via + // loadStored — loadLive(id, undefined) would (correctly) look ONLY in the + // no-cwd bucket and miss a materialized session that lives in a real cwd. + const probe = state !== undefined + ? await this.backend.loadLive(id, state.meta.cwd) + : await this.backend.loadStored(id) + return probe !== undefined } /** Remove a session and all its persisted artifacts. */ From c4bc6e0e38c3a9ed78c09e91b58009532831341c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 04:51:32 +0800 Subject: [PATCH 05/87] feat(agent): add queue-aware Agent.cancel() primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit abort() only kills the in-flight step, so a queued-but-not-yet-started prompt ran to completion after a cancel and a prompt accepted right after could be batched into the cancelled turn (the loop merges queued messages into one turn). This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb. cancel() clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY point a turn could start or continue: - right after the idle wait (window 1): drop the about-to-run turn and settle whenIdle() waiters directly (no running→idle transition fires, and no agent/status is emitted, so an ACP listener can't see a spurious idle that resolves a freshly-queued prompt as cancelled); - after the synchronous setStatus('running') emit (window 2): a running listener can cancel in the gap before runTurn; - in the step-start window (before runStep, after setAbort): a synchronous turn-start/step-start listener can cancel before any AbortController exists; - at the continuation gate: a cancel during the continuation waterfall (the finished step's controller already cleared) ends the turn aborted. The marker is ARMED only when there is something to cancel (running, an in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it set to drop a later prompt — and RESET unconditionally once per loop iteration, so it governs exactly one turn and never leaks onto the next prompt (even when a send() lands in the cancelled turn's flush window). ACP session/cancel now maps to agent.cancel() (keeping the synchronous settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so the ACP README narrows the remaining best-effort window to teardown only. Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2 leak guard: idle cancel then a prompt runs; mid-step, continuation, both pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates the stale pre-step test to the queue-aware guarantee. The existing cancel snapshot golden is byte-identical (it drives the new cancel() path end-to-end through the real subprocess), so no new golden is needed. 100% coverage. --- docs/architecture.md | 1 + packages/acp/README.md | 4 +- packages/acp/src/index.ts | 30 ++- packages/acp/tests/turns.spec.ts | 63 ++++- packages/agent-loop/README.md | 2 + packages/agent-loop/src/agent.ts | 41 ++++ packages/agent-loop/src/inbox.ts | 10 + packages/agent-loop/src/loop.ts | 82 ++++++- packages/agent-loop/tests/cancel.spec.ts | 279 +++++++++++++++++++++++ packages/agent/README.md | 3 +- packages/agent/src/types.ts | 19 ++ packages/agent/tests/agent.spec.ts | 1 + 12 files changed, 504 insertions(+), 31 deletions(-) create mode 100644 packages/agent-loop/tests/cancel.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 81a478b714..e28a384eb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,6 +109,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/2026-06-15-turn-enclosure-invariant.md)). - `abort(reason)` — aborts the in-flight step via `AbortSignal` +- `cancel(reason)` — the broad cancel: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. `abort()` is the narrower step-only verb; `cancel()` is what a UI/ACP `session/cancel` maps to. - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. - `session`, `status`, `options` diff --git a/packages/acp/README.md b/packages/acp/README.md index 7db1ee8785..0bacaeef07 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -27,7 +27,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | | `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | -| `session/cancel` | `agent.abort()` | aborts a running step + settles the prompt `cancelled` for ONLY that session — a cancel never touches another session's stream or prompt (see limitation below) | +| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | | `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) | ## Multi-session @@ -66,7 +66,7 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as ## Known limitations (tracked TODOs) - **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. -- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` (and teardown/disconnect) is honest RPC/UI cancellation plus best-effort abort: a *running* step is aborted, but a turn that is queued-but-not-yet-started (the gap before `agent.abort()` has an `AbortController` to signal) may still run to completion. This same window means disposal/disconnect can return while one short queued turn per session still runs, and a prompt accepted right after a pre-step cancel can be batched into the cancelled turn (the loop merges queued messages into one turn). A loop-level queue-aware cancel will close this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session. +- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` is now the queue-aware `agent.cancel()` (a running step is aborted, queued + steering work is cleared, and a turn about to start is dropped), so a queued-but-not-yet-started prompt no longer runs and a later prompt cannot be batched into the cancelled turn. **Teardown/disconnect still use the older `agent.abort('disposed')` + `whenIdle()`**, so the best-effort window remains there: disposal/disconnect can return while one short queued turn per session still runs. PR D's per-agent disposer switches teardown to the queue-aware path and closes this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session until then. - **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains each agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agents linger in `ctx.agents` until the host context disposes. A reconnect spins up a fresh context, so this strands no work; a per-agent disposal seam is the follow-up. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 780e907559..040f4b10d1 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -569,23 +569,19 @@ export function apply(ctx: Context, config: AcpConfig): void { cancel(params: CancelNotification): Promise { const rec = sessions.get(params.sessionId) if (rec === undefined) return Promise.resolve() - // RFC 010: session/cancel maps to agent.abort(reason). This aborts a - // RUNNING step (the turn ends 'aborted' → 'cancelled' via turn-end). - // It aborts and settles ONLY this session's agent/prompt — a cancel in - // one session never touches another's stream or pending prompt (RFC 011 - // isolation). It also settles the in-flight prompt as cancelled directly, - // in case the abort lands in the pre-step window (queued-but-not-started) - // where abort() has no AbortController to signal — see the README - // TODO(rfc010-cancel-prestep): a not-yet-started queued turn may still - // run to completion until a loop-level cancel lands. Best-effort abort - // plus honest RPC/UI cancellation. A secondary consequence of that same - // gap: because the loop batches all queued messages into one turn, a - // prompt accepted right after a pre-step cancel can be merged into the - // same turn as the cancelled one — that turn then carries both prompts' - // text and the new prompt settles for it. Both are closed by the same - // queue-aware loop cancel; the single-in-flight rule bounds the blast - // radius to one extra prompt. - rec.agent.abort('session/cancel') + // session/cancel maps to the queue-aware agent.cancel(reason): it aborts + // a RUNNING step, clears the queued + steering FIFOs, and drops a + // turn that is about to start (the pre-step window) — so a queued-but- + // not-yet-started prompt never runs, and a prompt accepted right after + // cannot be batched into the cancelled turn. Scoped to THIS session's + // agent — a cancel in one session never touches another's stream or + // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt + // as cancelled directly here: do NOT rely on the resulting turn/end to + // settle it, because cancel() may drop the turn before any turn/end is + // emitted, and removing this direct settle would move the RPC's + // resolution onto the settleFromLog/agent-status path, changing its + // timing. + rec.agent.cancel('session/cancel') settlePrompt(rec, 'cancelled') return Promise.resolve() }, diff --git a/packages/acp/tests/turns.spec.ts b/packages/acp/tests/turns.spec.ts index 19c3be2556..014dfecf91 100644 --- a/packages/acp/tests/turns.spec.ts +++ b/packages/acp/tests/turns.spec.ts @@ -327,21 +327,64 @@ describe('acp bridge — turn outcomes', () => { expect(res.stopReason).toBe('cancelled') }) - it('cancel in the pre-step window still settles the prompt cancelled exactly once', async () => { - // No script entry is consumed before cancel: cancel immediately after the - // prompt is sent, before the model step starts. The prompt must still - // settle cancelled (best-effort abort + settle), not hang. - harness = await makeBridgeHarness({ storageDir, script: [textResponse('late')] }) + it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => { + // Over the async JSON-RPC transport the loop usually wakes before cancel + // arrives, so this is a running/mid-step cancel (the synchronous pre-step + // DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee: + // the prompt settles cancelled, the agent reaches idle, and no second/leaked + // turn runs afterward. + harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] }) const sessionId = await newSession(harness) const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - // The queued turn may still start after the cancel cleared the in-flight - // slot (the documented TODO(rfc010-cancel-prestep) best-effort window): its - // turn-start then fires with no prompt to tag, and the bridge does nothing. - // Let it run to completion and assert nothing re-settles (no throw, no hang). - await harness.ctx.agents.get(sessionId)!.whenIdle() + const agent = harness.ctx.agents.get(sessionId)! + await agent.whenIdle() + // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so + // no second turn was batched or leaked. (A best-effort abort that left queued + // work could have started a second turn.) + const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length + expect(turnStarts).toBeLessThanOrEqual(1) + }) + + it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => { + // The ACP bridge settles the cancel RPC synchronously and accepts the next + // prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO + // whenIdle() between, the production race. An idle cancel must be a no-op that + // does NOT drop the following prompt. + harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) + const sessionId = await newSession(harness) + // Cancel while idle (no prompt in flight) — a no-op. + await harness.client.cancel({ sessionId }) + // Immediately prompt, no whenIdle() between. + const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(res.stopReason).toBe('end_turn') + const text = harness.updates + .filter(u => u.sessionUpdate === 'agent_message_chunk') + .map(u => (u.content.type === 'text' ? u.content.text : '')) + .join('') + expect(text).toContain('real answer') + }) + + it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => { + // Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence + // (the synchronous-settle path). The new prompt must run — the cancel marker + // must not leak onto it. + harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] }) + const sessionId = await newSession(harness) + const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] }) + await new Promise(r => setTimeout(r, 30)) + await harness.client.cancel({ sessionId }) + expect((await a).stopReason).toBe('cancelled') + // Immediately — no whenIdle() — send the next prompt. + const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] }) + expect(b.stopReason).toBe('end_turn') + const text = harness.updates + .filter(u => u.sessionUpdate === 'agent_message_chunk') + .map(u => (u.content.type === 'text' ? u.content.text : '')) + .join('') + expect(text).toContain('next answer') }) it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => { diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md index 308e5f022b..e5e3a03d2d 100644 --- a/packages/agent-loop/README.md +++ b/packages/agent-loop/README.md @@ -66,6 +66,8 @@ forever: Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. +Cancellation: `agent.abort()` aborts only the in-flight step; `agent.cancel()` is the broad verb — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. + ### What is NOT here Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index e964685b86..72bc1b0455 100644 --- a/packages/agent-loop/src/agent.ts +++ b/packages/agent-loop/src/agent.ts @@ -26,6 +26,14 @@ export class ReactLoopAgent implements Agent { private _status: AgentStatus = 'idle' private currentAbort: AbortController | undefined + /** + * Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the + * driver loop (via the LoopHandle) at every point a turn could start or + * continue. Armed ONLY when there is something to cancel (a running turn, an + * in-flight step, or queued/steering work), so an idle no-op cancel cannot + * leave it set to wrongly drop a later prompt. + */ + private cancelRequested = false private disposed: Promise private resolveDisposed!: () => void /** Resolves when the driver loop has fully exited (tests/disposal). */ @@ -176,6 +184,30 @@ export class ReactLoopAgent implements Agent { this.currentAbort?.abort(reason ?? 'aborted') } + cancel(reason?: string): void { + // Arm-gate: only mark a cancellation when there is actually work to cancel — + // a running turn, an in-flight step, or queued/steering work. An idle cancel + // with nothing pending is a true no-op; arming the marker then would wrongly + // drop the NEXT legitimate prompt (the marker is consumed only at the loop's + // turn-decision points, which an idle parked loop does not reach until woken + // by a real send()). Note the gate canNOT be `status === 'running'` alone: + // the pre-step window (a send() queued but the loop not yet flipped to + // running) has status `idle` with `hasQueued` true, and the marker exists + // precisely to cover it. + if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) { + this.cancelRequested = true + } + // Drop all pending queued + steering work (un-started prompts never run; the + // cancelled turn's steering is not re-enqueued). Cleared directly even when + // the loop is parked in waitForQueued — there is no turn to stop and nothing + // left for the parked loop to run, so no wake is needed. + this.inbox.clear() + // Interrupt an in-flight step immediately (the running turn observes the + // abort and ends `aborted`). The marker covers the windows where no step is + // running (pre-step, continuation). + this.currentAbort?.abort(reason ?? 'cancelled') + } + /** * Resolve once the agent has reached quiescence after settling out of * `running`. If it is already disposed, awaits {@link done} (the loop-exit @@ -218,6 +250,15 @@ export class ReactLoopAgent implements Agent { setAbort: controller => void (this.currentAbort = controller), disposed: this.disposed, isDisposed: () => this._status === 'disposed', + isCancelled: () => this.cancelRequested, + clearCancel: () => { this.cancelRequested = false }, + // Settle whenIdle() waiters WITHOUT a status transition — the pre-step + // cancel-skip path drops the about-to-run turn and re-parks without ever + // flipping running→idle, so a waiter registered in the pre-step window + // (status idle, hasQueued was true) would otherwise hang. This emits no + // agent/status, so an ACP agent/status listener never sees a spurious idle + // that would resolve a freshly-queued prompt as cancelled. + settleIdle: () => { this.settleIdleWaiters() }, }) // The disposer must be infallible: it runs inside the fiber's LIFO // disposal chain, where a throw would skip later disposers (e.g. the diff --git a/packages/agent-loop/src/inbox.ts b/packages/agent-loop/src/inbox.ts index 7aabad0166..a7b2e64e2c 100644 --- a/packages/agent-loop/src/inbox.ts +++ b/packages/agent-loop/src/inbox.ts @@ -52,6 +52,16 @@ export class Inbox { return this.steeringMessages.splice(0) } + /** + * Discard all pending messages (queued + steering) without delivering them — + * used by `cancel()`, which drops un-started work rather than draining it into + * a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away. + */ + clear(): void { + this.queuedMessages.length = 0 + this.steeringMessages.length = 0 + } + /** Wait until a queued message arrives or `cancel` resolves. */ waitForQueued(cancel: Promise): Promise { if (this.hasQueued) return Promise.resolve() diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index dc1cbcf278..906467e6b4 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -107,6 +107,26 @@ export interface LoopHandle { /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise isDisposed(): boolean + /** + * Whether a `cancel()` is pending for the current turn. The driver checks this + * at every decision point where a turn could start or continue (right after + * the idle wait, after the `running` flip, before each step, and at the + * continuation gate) and drops the about-to-run / continuing turn. Reset once + * per loop iteration via {@link clearCancel} after the turn returns, so the + * marker governs exactly one cancellation and never leaks to a later prompt. + */ + isCancelled(): boolean + /** Clear the cancel marker (called once per iteration after the turn returns). */ + clearCancel(): void + /** + * Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the + * pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the + * idle wait, so no `running→idle` transition fires to settle a `whenIdle()` + * waiter that was registered in the pre-step window — this settles it directly + * (it emits no `agent/status`, so an ACP `agent/status` listener never sees a + * spurious idle that would resolve a freshly-queued prompt as cancelled). + */ + settleIdle(): void } /** @@ -148,7 +168,33 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH await agent.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break + // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the + // idle wait but before we flip to `running`. Drop the about-to-run turn: the + // queued/steering work is already cleared by `cancel()`, and we settle any + // `whenIdle()` waiter DIRECTLY (no status transition fires here, so the + // running→idle settle never runs) WITHOUT emitting `agent/status` (an ACP + // listener must not see a spurious idle that resolves a freshly-queued prompt + // as cancelled). Clear the marker and re-park. + if (handle.isCancelled()) { + handle.clearCancel() + handle.settleIdle() + continue + } + handle.setStatus('running') + + // Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` + // SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the + // check above and `runTurn`. `cancel()` already cleared the queued FIFO, so + // drop the turn before it starts (runTurn would otherwise throw on an empty + // queue) and transition back to idle — `running` was already emitted, so a + // real `idle` transition (which also settles waiters) balances the status. + if (handle.isCancelled()) { + handle.clearCancel() + handle.setStatus('idle') + continue + } + // Re-derive the turn number from the log each iteration (do NOT keep a local // counter): an idle `agent.inject()` can append its own one-shot turn while // the loop waits above, so the next real turn must continue from whatever @@ -170,8 +216,18 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } catch { /* contained: a throwing agent/error listener must not kill the driver */ } } + // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and + // before the next iteration's idle wait. NOT gated on the idle transition + // below: a `send()` that lands during the cancelled turn's flush window makes + // `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset + // would never fire and the stale marker would wrongly drop that next prompt's + // turn. Resetting per iteration scopes the marker to exactly the turn that was + // cancelled. + handle.clearCancel() + // Steering that arrived too late to join this turn (turn-end listeners, - // flush) becomes a queued message — it must never be stranded. + // flush) becomes a queued message — it must never be stranded. (A cancelled + // turn already cleared its steering, so there is nothing to re-enqueue.) for (const message of agent.inbox.drainSteering()) { agent.inbox.enqueue(message) } @@ -323,6 +379,20 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const abort = new AbortController() handle.setAbort(abort) + // Cancel landing in the step-start window: a synchronous `agent/turn-start` + // or `agent/step-start` listener (both fire before this point) can have + // called `cancel()`, and `runStep` would otherwise run a full extra step + // with no AbortController having observed it. Check the marker AFTER + // setAbort (so the next-iteration drain sees a clean controller) and before + // `runStep`: drop the step, end the turn `aborted`. closeStep balances the + // already-appended step/start. + if (handle.isCancelled()) { + handle.setAbort(undefined) + reason = { kind: 'aborted', reason: 'cancelled' } + closeStep() + break + } + let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { stepOutcome = await runStep(ctx, agent, turn, step, abort.signal) @@ -382,6 +452,16 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // next iteration's drain records it. if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true + // A cancel that landed during the continuation window — after the step's + // AbortController was cleared (setAbort(undefined)) but before the next + // step starts — has no controller to observe it, so the turn-scoped marker + // ends the turn here. cancel() also cleared the steering FIFO, so the + // override above did not re-arm continuation. + if (handle.isCancelled()) { + reason = { kind: 'aborted', reason: 'cancelled' } + break + } + if (!shouldContinue || handle.isDisposed()) { /* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */ if (handle.isDisposed()) reason = { kind: 'disposed' } diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/agent-loop/tests/cancel.spec.ts new file mode 100644 index 0000000000..64b969811d --- /dev/null +++ b/packages/agent-loop/tests/cancel.spec.ts @@ -0,0 +1,279 @@ +/** + * Tests for the queue-aware `Agent.cancel()` primitive (PR C). `cancel()` is the + * broad verb — it clears queued + steering work, aborts an in-flight step, and + * drops a turn about to start — whereas `abort()` kills only the current step. + * These tests exercise every window where a cancel can land (idle, pre-step, + * mid-step, continuation) and the marker's arm/reset rules that keep a cancel + * from leaking to a later prompt or hanging `whenIdle()`. + * + * @module dsh-agent-loop/tests/cancel + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter) { + 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: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function send(agent: ReactLoopAgent, text: string) { + agent.send([{ type: 'text', text }]) +} + +/** Resolve on the agent's next idle transition (event-based, not status poll). */ +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +/** All user-message texts recorded in the log (to assert what actually ran). */ +function userTexts(agent: ReactLoopAgent): string[] { + return agent.session.events + .filter(e => e.type === 'user/message') + .flatMap(e => e.type === 'user/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) +} + +describe('Agent.cancel()', () => { + it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // The loop is parked at the idle wait with nothing queued. A cancel here must + // NOT arm the marker — otherwise the next legitimate prompt would be dropped. + agent.cancel('nothing to cancel') + + send(agent, 'real prompt') + await waitForIdle(ctx, agent) + + // The prompt ran: its user message is in the log and one turn completed. + expect(userTexts(agent)).toEqual(['real prompt']) + expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + }) + + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // send() queues synchronously (status still idle, loop microtask not yet + // resumed). Cancel in that pre-step window: the queued turn must not run. + send(agent, 'drop me') + agent.cancel('pre-step') + + // Give the loop a chance to wake and process the cancel. + await new Promise(r => setTimeout(r, 30)) + + // No turn was opened — the queued prompt was dropped, never recorded. + expect(userTexts(agent)).toEqual([]) + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.status).toBe('idle') + }) + + it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { + const adapter = new MockAdapter([textResponse('x')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // Queue work, then register a whenIdle() waiter while in the pre-step window + // (status idle, hasQueued true) — it does NOT take the fast path. Then cancel. + // The skip path must settle this waiter directly (no running→idle transition + // ever fires), or it would hang forever. + send(agent, 'q') + const idle = agent.whenIdle() + agent.cancel('pre-step') + + // Must resolve (not hang). A timeout makes the failure a clear test failure. + await Promise.race([ + idle, + new Promise((_r, reject) => setTimeout(() => { reject(new Error('whenIdle hung after pre-step cancel')) }, 1000)), + ]) + expect(agent.status).toBe('idle') + }) + + it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + expect(agent.status).toBe('running') + agent.cancel('mid-step') + await waitForIdle(ctx, agent) + + expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }]) + }) + + it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel() // no reason → default 'cancelled' + await waitForIdle(ctx, agent) + + expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + }) + + it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { + const adapter = new MockAdapter(['hang', textResponse('second reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // First turn hangs; cancel it mid-step. + send(agent, 'first') + await new Promise(r => setTimeout(r, 30)) + agent.cancel('cancel first') + await waitForIdle(ctx, agent) + + // The marker must have been reset after the cancelled turn — a fresh prompt + // runs to completion rather than being dropped by a stale marker. + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(userTexts(agent)).toContain('second') + // The second turn completed (its reply was streamed). + const reasons = agent.session.events.filter(e => e.type === 'turn/end') + expect(reasons.length).toBe(2) + }) + + it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => { + const adapter = new MockAdapter([textResponse('should not stream')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // A turn-start listener fires BEFORE any AbortController is installed for the + // step. Cancelling there must still drop the step (the turn-scoped marker, + // not abort(), is what catches this) — no model step runs. + let streamed = false + ctx.on('agent/stream-chunk', () => { streamed = true }) + const dispose = ctx.on('agent/turn-start', (subject) => { + if (subject === agent) agent.cancel('from turn-start') + }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + // No step streamed (the model never ran), and the turn ended aborted. + expect(streamed).toBe(false) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + }) + + it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { + // A continuation-waterfall listener cancels DURING the continuation decision + // (the finished step's AbortController is already cleared), and votes to + // continue — but the turn-scoped marker checked right after must end the turn + // `aborted` and run NO second step. + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + let steps = 0 + ctx.on('agent/step-start', () => { steps += 1 }) + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + + let continued = false + ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { + if (subject === agent && !continued) { + continued = true + agent.cancel('from continuation') + return true // vote to continue — the post-waterfall marker check must override + } + return next() + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // Only ONE step ran (the second was cancelled in the continuation window), + // and the turn ended aborted. + expect(steps).toBe(1) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + }) + + it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running + // listener can cancel in the gap between the loop's pre-step check and + // runTurn. The second check (after the running flip) must drop the turn — + // runTurn would otherwise throw on the now-empty queue. + let streamed = false + ctx.on('agent/stream-chunk', () => { streamed = true }) + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'running') agent.cancel('from running listener') + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + // No turn opened, no step streamed, and a later prompt still runs (the marker + // was reset). + expect(streamed).toBe(false) + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + }) + + it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + expect(agent.status).toBe('running') + // Steer (joins the running turn's steering FIFO), then cancel: the steering + // must be dropped, NOT re-enqueued as a new queued turn. + agent.steer([{ type: 'text', text: 'steer text' }]) + agent.cancel('cancel with steering') + await waitForIdle(ctx, agent) + + // After the cancelled turn settles, the agent is idle with NO follow-up turn + // started from the dropped steering. + await new Promise(r => setTimeout(r, 30)) + expect(agent.status).toBe('idle') + const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + expect(turnStarts.length).toBe(1) // only the original (cancelled) turn + // The steering text was dropped — it never reached the log. + const flat = agent.session.events + .filter(e => e.type === 'steering/message') + .flatMap(e => e.type === 'steering/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) + expect(flat).not.toContain('steer text') + }) +}) diff --git a/packages/agent/README.md b/packages/agent/README.md index 4380cd756d..d815761ae4 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -54,7 +54,8 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md)) -- `agent.abort(reason?)` — abort the in-flight step +- `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) +- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 762d325068..0ebf582bda 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -81,6 +81,25 @@ export interface Agent { /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ abort(reason?: string): void + /** + * Cancel ALL pending work for the agent — the narrower {@link abort} kills + * only the in-flight step. `cancel()`: + * + * - clears the queued FIFO (un-started prompts never run) and the steering + * FIFO (steering for the cancelled turn is dropped, not re-enqueued); + * - aborts the in-flight step if one is running (the turn ends `aborted`); + * - drops a turn that is about to start (a `cancel()` landing in the + * pre-step window — after a `send()` queued but before the loop flips to + * `running`, or after `running` is emitted but before the first step) so + * that queued prompt does not run and cannot be batched into the cancelled + * turn. + * + * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. + * `cancel()` on an idle agent with nothing queued or running is a safe no-op + * — it does NOT arm anything that would drop a later legitimate prompt. + */ + cancel(reason?: string): void + /** * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. The diff --git a/packages/agent/tests/agent.spec.ts b/packages/agent/tests/agent.spec.ts index 1994c785a6..28072154f6 100644 --- a/packages/agent/tests/agent.spec.ts +++ b/packages/agent/tests/agent.spec.ts @@ -14,6 +14,7 @@ function stubAgent(rawId: string): Agent { steer() {}, inject() {}, abort() {}, + cancel() {}, whenIdle() { return Promise.resolve() }, } } From 9ee22bc6f69c9912c327c4cafbdd55e3d04c0823 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:10:16 +0800 Subject: [PATCH 06/87] fix(agent): don't resolve whenIdle() early on pre-step cancel + requeue (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's converge pass found a quiescence-contract violation: a whenIdle() waiter registered for prompt A, then cancel() clears A, then prompt B is queued BEFORE the loop resumes from the idle wait. The window-1 cancel branch called settleIdle() UNCONDITIONALLY, resolving the waiter while B was still queued-and-unrun — whenIdle() resolved with zero events, then B ran afterward. Fix: in window 1, only settleIdle() + re-park when NO new work is queued. If a send() raced in after the cancel, the marker was for the cancelled work only — clear it and fall through to run the new prompt's turn, letting THAT turn's running→idle settle the waiter (so whenIdle() waits for B to actually run). Adds a regression test reproducing the exact interleaving (send A → whenIdle → cancel → send B): whenIdle() now resolves only after B's turn ran (B's user message + a turn/end in the log), and A was dropped. --- packages/agent-loop/src/loop.ts | 24 +++++++++++++++-------- packages/agent-loop/tests/cancel.spec.ts | 25 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index 906467e6b4..1c78f49beb 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -169,16 +169,24 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH if (handle.isDisposed()) break // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the - // idle wait but before we flip to `running`. Drop the about-to-run turn: the - // queued/steering work is already cleared by `cancel()`, and we settle any - // `whenIdle()` waiter DIRECTLY (no status transition fires here, so the - // running→idle settle never runs) WITHOUT emitting `agent/status` (an ACP - // listener must not see a spurious idle that resolves a freshly-queued prompt - // as cancelled). Clear the marker and re-park. + // idle wait but before we flip to `running`. The cancelled queued/steering + // work is already cleared by `cancel()`. Clear the marker, then: + // - if NOTHING new is queued, drop the about-to-run turn and re-park, + // settling any `whenIdle()` waiter DIRECTLY (no running→idle transition + // fires here to settle it) and WITHOUT emitting `agent/status` (an ACP + // listener must not see a spurious idle that resolves a freshly-queued + // prompt as cancelled); + // - if a NEW prompt was queued AFTER the cancel (a send() that raced in + // before the loop resumed), the marker was for the cancelled work only — + // fall through and run the new prompt's turn. Do NOT settle waiters here: + // a whenIdle() waiter must wait for that new turn's running→idle, not + // resolve before it runs (the quiescence contract). if (handle.isCancelled()) { handle.clearCancel() - handle.settleIdle() - continue + if (!agent.inbox.hasQueued) { + handle.settleIdle() + continue + } } handle.setStatus('running') diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/agent-loop/tests/cancel.spec.ts index 64b969811d..b8c7a4f78e 100644 --- a/packages/agent-loop/tests/cancel.spec.ts +++ b/packages/agent-loop/tests/cancel.spec.ts @@ -249,6 +249,31 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) + it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => { + // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() + // clears A; prompt B is queued BEFORE the loop resumes from the idle wait. + // The window-1 cancel branch must NOT settle the waiter while B is still + // queued-and-unrun — whenIdle() must wait for B's turn to actually run and + // settle (the quiescence contract), not resolve before B's first event. + const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + send(agent, 'A') // queues A (status still idle, loop microtask pending) + const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) + agent.cancel('drop A') // arms marker, clears A + send(agent, 'B') // B races in before the loop resumes + + // whenIdle() must resolve only AFTER B's turn fully ran — by which point B's + // user message and a turn/end are in the log. (Before the fix it resolved + // immediately, with zero events, then B ran afterward.) + await idle + expect(userTexts(agent)).toContain('B') + expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + // A was dropped (never ran); only B's turn is recorded. + expect(userTexts(agent)).not.toContain('A') + }) + it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) From 2a4d89a4bd0a9d365e74fbb64baf7685f4bf3972 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 06:44:35 +0800 Subject: [PATCH 07/87] feat(agent): return an AgentHandle with an async per-agent disposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam) now returns `AgentHandle = { agent; dispose(): Promise }` instead of a bare `Agent`. The disposer is a capability: only the holder can tear down exactly this agent — stop its loop, await the loop's exit (true quiescence, not just the `disposed` status flip), unregister it, and remove its session from the store. The teardown ORDER is load-bearing for durability. The loop appends its final `turn/end` + runs `session/flush` AFTER an abort, delivered through `session.onAppend` → `session/event`; if the session-store effect (which detaches `onAppend`) were torn down first, those closing events would never reach persistence. So `dispose()`: 1. runs the register+start effect disposer (sync: request loop stop), 2. `await agent.done` (loop exits, final flush captured), THEN 3. runs the session disposer (detach onAppend + delete store entry). `SessionStore.createOwned()` exposes the session-create effect's disposer (plain `create()` discards it — fiber-owned). `AgentLoop` funnels both factory entrypoints (`createAgent`, `resumeWith`) through a shared `startOwned` that composes the ordered teardown; the config path keeps a fiber-owned agent by discarding the handle. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the owner that created it. --- packages/agent-loop/src/index.ts | 75 +++++++++++++++---- .../tests/config-session-id.spec.ts | 2 +- packages/agent-loop/tests/resume.spec.ts | 20 ++--- packages/agent/src/index.ts | 38 ++++++++-- packages/agent/tests/agent.spec.ts | 14 +++- packages/session/src/index.ts | 25 ++++++- 6 files changed, 133 insertions(+), 41 deletions(-) diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index f118959fce..7cc90ba3da 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -11,7 +11,7 @@ import { Context, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' import { AgentId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -120,23 +120,28 @@ export class AgentLoop extends Service implements AgentFactory { */ create(id: string, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) + // Config/programmatic path: the session is owned by THIS fiber (the plain + // create()), so disposing the AgentLoop/caller fiber removes it. No + // AgentHandle is needed — the register+start effect is fiber-owned too. const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} }) - return this.start(AgentId(id), options, session) + const { agent } = this.start(AgentId(id), options, session) + return agent } /** * Programmatic factory create ({@link AgentFactory}): an agent on a * caller-supplied `sessionId` (NOT `${id}-session`), with optional session * metadata (validated `cwd`, lineage). The ACP bridge uses this so the - * client-generated session id becomes the live/persisted session id. + * client-generated session id becomes the live/persisted session id. Returns + * an {@link AgentHandle} the owner disposes to tear down exactly this agent. */ - createAgent(options: CreateAgentOptions): Agent { + createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE creating the session: register() would reject a // duplicate id only AFTER sessions.create(), leaving an orphaned live // session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) - const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} }) - return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session) + const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} }) + return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned) } /** @@ -151,7 +156,7 @@ export class AgentLoop extends Service implements AgentFactory { * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. */ - async resume(options: ResumeAgentOptions): Promise { + async resume(options: ResumeAgentOptions): Promise { // Read the service through `ctx.get('sessionPersistence')` — a direct // global-store lookup keyed by the isolate symbol — NOT // `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject @@ -183,7 +188,7 @@ export class AgentLoop extends Service implements AgentFactory { * sessions store + registry are still read through `this.ctx` (both are in * AgentLoop's static inject, so they resolve fine). */ - private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { + private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { this.assertAgentIdFree(options.agentId) const { meta, events } = await persistence.load(SessionId(options.resumeSessionId)) // Re-check the agent id AFTER the await: the pre-load check above can go @@ -196,7 +201,7 @@ export class AgentLoop extends Service implements AgentFactory { // events make lastTurnNumber/deriveMessages continue; the backend already // has state (cursor) from the load above, so onCreated is a no-op and the // seed is not re-persisted. - const session = this.ctx.sessions.create(options.resumeSessionId, { + const owned = this.ctx.sessions.createOwned(options.resumeSessionId, { seed: events, meta: { createdAt: meta.createdAt, @@ -204,7 +209,7 @@ export class AgentLoop extends Service implements AgentFactory { ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, }, }) - return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session) + return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned) } /** @@ -219,16 +224,54 @@ export class AgentLoop extends Service implements AgentFactory { } } - /** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */ - private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent { + /** + * Shared: construct a ReactLoopAgent, register it, and start its loop. The + * register + loop-stop disposers live in ONE generator effect so they run + * LIFO on dispose (the loop-stop disposer — yielded last — runs first, then + * the registry unregister), so a throwing stop() cannot leak the registry + * entry. Returns the agent plus the effect's disposer (`disposeAgent`); the + * effect is owned by the caller fiber, so disposing that fiber also tears the + * agent down — the disposer is for an OWNER that needs to tear down ONE agent. + */ + private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise } { const agent = new ReactLoopAgent(this.ctx, id, options, session) - // Generator effect: stop and unregister are independent disposables - // (LIFO), so a throwing stop() cannot leak the registry entry. - this.ctx.effect(function* (this: AgentLoop) { + const dispose = this.ctx.effect(function* (this: AgentLoop) { yield this.ctx.agents.register(agent) yield agent.start() }.bind(this), 'agentLoop.start()') - return agent + return { agent, disposeAgent: async () => { await dispose() } } + } + + /** + * Build an {@link AgentHandle} for an OWNED session + agent. The handle's + * `dispose()` tears down exactly this agent in the order durability requires: + * + * 1. run `disposeAgent` — the register+start effect's disposer. LIFO runs + * `agent.start()`'s (synchronous) disposer first: it sets `disposed`, + * aborts the in-flight step, and unblocks the loop's idle wait. Then the + * registry unregister runs. The loop has NOT necessarily exited yet — the + * start disposer only REQUESTS exit, it does not await it. + * 2. `await agent.done` — the loop-exit promise. The loop unwinds and runs + * its final `session/flush` + `turn/end`, delivered through the still- + * attached `session.onAppend` → `session/event`, so persistence captures + * the closing events. Only now is the agent truly quiescent. + * 3. run the session disposer — detach `onAppend` and remove the store + * entry. Done LAST so step 2's final flush is not dropped. + */ + private startOwned( + id: AgentId, + options: AgentOptions, + owned: { session: Session; dispose: () => Promise }, + ): AgentHandle { + const { agent, disposeAgent } = this.start(id, options, owned.session) + return { + agent, + dispose: async () => { + await disposeAgent() // stop the loop (sync) + unregister + await agent.done // wait for the loop to actually exit (final flush captured) + await owned.dispose() // detach onAppend + remove the session store entry + }, + } } } diff --git a/packages/agent-loop/tests/config-session-id.spec.ts b/packages/agent-loop/tests/config-session-id.spec.ts index 13a62bca8c..d4753432bc 100644 --- a/packages/agent-loop/tests/config-session-id.spec.ts +++ b/packages/agent-loop/tests/config-session-id.spec.ts @@ -78,7 +78,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/agent-loop/tests/resume.spec.ts b/packages/agent-loop/tests/resume.spec.ts index 10313fdd26..bc655a32f2 100644 --- a/packages/agent-loop/tests/resume.spec.ts +++ b/packages/agent-loop/tests/resume.spec.ts @@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const agent = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } }) + const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() @@ -63,7 +63,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const agent = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' }) + const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') await ctx2.fiber.dispose() @@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c9181081a3..2063812963 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -54,6 +54,23 @@ export interface ResumeAgentOptions { agentOptions?: AgentOptions } +/** + * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / + * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder + * can tear this agent down. `dispose()` unregisters the agent, stops its loop, + * awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and + * removes the agent's session from the store, in an order that captures the + * loop's final `session/flush` before the session is detached. + * + * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only + * for the OWNER that created it. Config-created agents (the loop's own startup) + * are owned by the loop fiber and never need a handle. + */ +export interface AgentHandle { + agent: Agent + dispose(): Promise +} + /** * The agent-creation factory the loop implementation provides to the registry * via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so @@ -61,14 +78,18 @@ export interface ResumeAgentOptions { * depending on the concrete `dsh-agent-loop` package. */ export interface AgentFactory { - /** Create, start, and register a new agent on a caller-supplied session id. */ - createAgent(options: CreateAgentOptions): Agent + /** + * Create, start, and register a new agent on a caller-supplied session id. + * Returns an {@link AgentHandle} — the owner disposes it to tear down exactly + * this agent (unregister + stop loop + await quiescence + remove session). + */ + createAgent(options: CreateAgentOptions): AgentHandle /** * Load a persisted session and resume an agent on it. Async because it awaits * `ctx.sessionPersistence.load`; must be called after that service exists - * (consumers inject `sessionPersistence`). + * (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}. */ - resume(options: ResumeAgentOptions): Promise + resume(options: ResumeAgentOptions): Promise } /** Thrown when create/resume is called before an agent factory is registered. */ @@ -107,9 +128,10 @@ export class AgentRegistry extends Service { * Create, start, and register a new agent through the registered factory. * Distinct from {@link register} (which records an already-constructed * agent): this constructs the agent and its session. Throws if no factory is - * registered. + * registered. Returns an {@link AgentHandle} — the owner disposes it to tear + * down exactly this agent. */ - create(options: CreateAgentOptions): Agent { + create(options: CreateAgentOptions): AgentHandle { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) return this.factory.createAgent(options) } @@ -117,9 +139,9 @@ 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. + * session persistence is not configured. Returns an {@link AgentHandle}. */ - async resume(options: ResumeAgentOptions): Promise { + async resume(options: ResumeAgentOptions): Promise { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) return this.factory.resume(options) } diff --git a/packages/agent/tests/agent.spec.ts b/packages/agent/tests/agent.spec.ts index 28072154f6..98f072ab10 100644 --- a/packages/agent/tests/agent.spec.ts +++ b/packages/agent/tests/agent.spec.ts @@ -82,8 +82,14 @@ describe('AgentRegistry factory seam', () => { function stubFactory() { const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] } const factory: import('@deepseek-ai/dsh-agent').AgentFactory = { - createAgent(options) { calls.create.push(options); return stubAgent(options.agentId) }, - resume(options) { calls.resume.push(options); return Promise.resolve(stubAgent(options.agentId)) }, + createAgent(options) { + calls.create.push(options) + return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + }, + resume(options) { + calls.resume.push(options) + return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + }, } return { factory, calls } } @@ -102,11 +108,11 @@ describe('AgentRegistry factory seam', () => { ctx.agents.setFactory(factory) const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }) - expect(created.id).toBe('c1') + expect(created.agent.id).toBe('c1') expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }]) const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' }) - expect(resumed.id).toBe('r1') + expect(resumed.agent.id).toBe('r1') expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }]) }) diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 4796c05f51..05fdb0c83e 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -230,6 +230,25 @@ export class SessionStore extends Service { * non-absolute path (storage backends key directories off it). */ create(id?: string, options?: CreateSessionOptions): Session { + // Discard the store-removal disposer: a plain create() is owned by the + // calling fiber (disposing the fiber removes the session). An owner that + // needs to remove ONE session independently uses createOwned(). + return this.createOwned(id, options).session + } + + /** + * Like {@link create}, but ALSO returns the disposer for the session's + * store-removal effect — so an owner can remove exactly THIS session (detach + * `onAppend`, delete the store entry) without disposing the whole fiber. + * + * Used by the agent factory's {@link AgentHandle} teardown: an owned agent's + * `dispose()` stops the loop, awaits quiescence, unregisters the agent, and + * THEN runs this session disposer — so the loop's final `session/flush` + * (delivered via `onAppend` → `session/event`) is captured before `onAppend` + * is detached. The disposer is async (a cordis effect disposer) to compose + * with the agent teardown's promise chain. + */ + createOwned(id?: string, options?: CreateSessionOptions): { session: Session; dispose: () => Promise } { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) const cwd = options?.meta?.cwd @@ -244,7 +263,7 @@ export class SessionStore extends Service { ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, } const session = new Session(sessionId, options?.seed, header) - this.ctx.effect(function* (this: SessionStore) { + const dispose = this.ctx.effect(function* (this: SessionStore) { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(sessionId, session) // Yield the rollback BEFORE emitting `session/created`: a generator @@ -259,7 +278,9 @@ export class SessionStore extends Service { } this.ctx.emit('session/created', session) }.bind(this), 'sessions.create()') - return session + // ctx.effect's disposer returns Promise; normalize to an always-async + // disposer for the owner. + return { session, dispose: async () => { await dispose() } } } get(id: string): Session | undefined { From ee4cad3ada7924b5bbb62e6c646f4975cb4009f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 06:44:58 +0800 Subject: [PATCH 08/87] feat(acp): dispose each session's agent on disconnect/teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge now holds each session's `AgentHandle` disposer in its `SessionRecord` and runs it on teardown (client disconnect or fiber dispose) instead of the old `abort()` + `whenIdle()` drain that left agents registered. A bare client disconnect now leaves NO registered agent and NO session-store entry — not an idled-but-still-registered one. The queue-aware `cancel()` inside the disposer also closes the former pre-step best-effort window (a turn about to start is dropped), so teardown reaches true quiescence. The `session/load`-races-teardown leak is fixed: if the bridge closed while `resume()` was pending, the just-resumed handle is disposed before throwing, so it leaves no orphan (it has no SessionRecord, so quiesce() never sees it). Tests: the disconnect test now asserts (through the SAME memoized teardown) that the agent is unregistered AND its session removed; a durability test re-loads the persisted log after dispose and asserts the closing turn/end is on disk (guards the teardown-order contract); a sibling-isolation test proves one handle's dispose() leaves other agents untouched. Docs: agent / agent-loop / acp READMEs, architecture.md, and the stale in-code quiesce() ownership comment updated to the per-agent disposal model; the now-resolved TODO(rfc010-agent-disposal) / TODO(rfc010-cancel-prestep) teardown notes removed. --- docs/architecture.md | 2 +- .../proposed/2026-06-14-acp-multi-session.md | 2 +- examples/coding-agent/tests/resume.e2e.ts | 6 +- packages/acp/README.md | 4 +- packages/acp/src/index.ts | 94 +++++++++++-------- packages/acp/tests/dispose.spec.ts | 87 +++++++++++++++-- packages/acp/tests/edges.spec.ts | 2 +- packages/agent-loop/README.md | 6 +- packages/agent/README.md | 6 +- 9 files changed, 150 insertions(+), 59 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index e28a384eb3..9a81b1c6d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` | `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions | | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | | `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall | -| `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam | +| `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 8cc2237eff..e4d7bbbe41 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's "real per-session disposer scope" is also deferred (`TODO(rfc010-agent-disposal)`): the bridge demuxes via id-keyed maps and global `ctx.on` listeners (correct and leak-free — disposal drains every session in parallel to quiescence), and a per-agent disposer seam is the follow-up. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. ## Problem diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index b720efa8c9..cf2d910138 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses agentId: 'resume-1', sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, - }) as ReactLoopAgent + }).agent as ReactLoopAgent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -51,11 +51,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // session. The loaded event log seeds the live session, so the model sees // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), root) - const resumed = await ctx.agents.resume({ + const resumed = (await ctx.agents.resume({ agentId: 'resume-2', resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, - }) as ReactLoopAgent + })).agent as ReactLoopAgent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/packages/acp/README.md b/packages/acp/README.md index 0bacaeef07..5cdfa3a202 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -61,13 +61,11 @@ A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical s ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, `agent.abort()`, then `await agent.whenIdle()` — the interface-level quiescence signal (NOT `agent/status('disposed')`, which fires before the driver exits). The agents drain in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop with the queue-aware `cancel()`, `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) - **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. -- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` is now the queue-aware `agent.cancel()` (a running step is aborted, queued + steering work is cleared, and a turn about to start is dropped), so a queued-but-not-yet-started prompt no longer runs and a later prompt cannot be batched into the cancelled turn. **Teardown/disconnect still use the older `agent.abort('disposed')` + `whenIdle()`**, so the best-effort window remains there: disposal/disconnect can return while one short queued turn per session still runs. PR D's per-agent disposer switches teardown to the queue-aware path and closes this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session until then. -- **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains each agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agents linger in `ctx.agents` until the host context disposes. A reconnect spins up a fresh context, so this strands no work; a per-agent disposal seam is the follow-up. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 040f4b10d1..7edc2dae0d 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -138,6 +138,13 @@ export const Config: Schema = Schema.object({ interface SessionRecord { sessionId: string agent: Agent + /** + * The owned-agent disposer (from the {@link AgentHandle} the factory returned). + * Teardown calls it to unregister this ONE agent, stop its loop, await + * quiescence, and remove its session — instead of leaving it for the bridge + * fiber to reclaim. + */ + dispose: () => Promise /** * Resolves tool-owned presentation for THIS session's tool calls and remembers * each in-flight call's `(name, args)` so the matching `tool/result` can find @@ -434,14 +441,21 @@ export function apply(ctx: Context, config: AcpConfig): void { validateWorkspaceParams(params) validateMcpServers(params) const sessionId = randomUUID() - const agent = agents.create({ + const handle = agents.create({ agentId: sessionId, sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), }) - bySession.set(agent, sessionId) - sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined }) + bySession.set(handle.agent, sessionId) + sessions.set(sessionId, { + sessionId, + agent: handle.agent, + dispose: () => handle.dispose(), + presenter: makePresenter(), + terminalEnabled: terminalOutputCap, + inflight: undefined, + }) return Promise.resolve({ sessionId }) }, @@ -483,30 +497,38 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) } } - const agent = await agents.resume({ + const handle = await agents.resume({ agentId: params.sessionId, resumeSessionId: params.sessionId, agentOptions: agentOptions(config), }) // The bridge may have torn down (disposal / client disconnect) while // resume() was pending. Its listeners are gone, so installing a record - // now would resurrect a live agent the bridge can no longer drive or - // tear down. Bail: the just-resumed agent is reclaimed with the host - // context (no per-agent disposer — TODO(rfc010-agent-disposal)). - /* v8 ignore next 3 -- the in-memory test transport rejects the in-flight + // now would resurrect a live agent the bridge can no longer drive. Bail — + // and tear down the just-resumed agent (unregister + stop + remove its + // session) before throwing, so it does not leak: it has no SessionRecord, + // so quiesce() would never see it. + /* v8 ignore next 4 -- the in-memory test transport rejects the in-flight session/load request the instant it closes (before this post-await code runs), so the guard can't be hit in tests; it protects the real stdio path, where a closed pipe need not reject a mid-flight handler. */ if (closed) { + await handle.dispose() throw invalidParams('connection closed during session/load') } + const agent = handle.agent bySession.set(agent, params.sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined, + sessionId: params.sessionId, + agent, + dispose: () => handle.dispose(), + presenter: makePresenter(), + terminalEnabled, + inflight: undefined, } sessions.set(params.sessionId, record) // Replay the persisted event log to the client as session/update. Use @@ -604,35 +626,27 @@ export function apply(ctx: Context, config: AcpConfig): void { /** * Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach - * quiescence"): for each session settle any pending prompt `cancelled`, abort - * the agent, and AWAIT it draining via the interface-level `whenIdle()` signal - * (NOT `agent/status('disposed')`, which fires before the driver exits). The - * agents drain in parallel. Idempotent — clears the `sessions` map first and - * memoizes, so a second call (close racing dispose) is a no-op. + * quiescence"): for each session settle any pending prompt `cancelled`, then + * run that session's {@link AgentHandle} `dispose()` — which stops the loop + * with the queue-aware cancel, AWAITS the loop's exit (the final + * `turn/end` + `session/flush` are captured while `onAppend` is still + * attached), unregisters the agent, and removes its session from the store. + * The per-session disposes run in parallel. Idempotent — clears the `sessions` + * map first and memoizes, so a second call (close racing dispose) is a no-op. * Shared by Cordis disposal AND client disconnect (`conn.closed`). * - * Caveat (same window as TODO(rfc010-cancel-prestep)): if teardown lands in - * the pre-step window — `agent.send()` queued a turn but the loop has not yet - * flipped to `running` — `abort()` has no live `AbortController` to signal and - * `whenIdle()` returns immediately (status is still `idle`), so that queued - * turn may still start and run after teardown returns. Reaching true - * quiescence in that window needs a queue-aware loop cancel primitive (a - * loop-level change); the single-in-flight-per-session rule bounds the worst - * case to one short queued turn per session. - * - * The agents are NOT individually disposed/unregistered here. The factory - * (`ctx.agents.create`/`resume`) registers each via `AgentLoop.start`'s - * `this.ctx.effect(...)`; because the factory is reached through this bridge's - * traceable service proxy, that effect's `this.ctx` is the CALLER context (the - * bridge fiber), so every registry entry is bound to the bridge fiber and is - * reclaimed when the bridge fiber disposes (whole-context dispose, or an - * ACP-only HMR `acpFiber.dispose()` — both unregister all the bridge's - * agents). What this teardown path handles is a bare client disconnect, which - * resolves `conn.closed` WITHOUT disposing the fiber: each live agent is - * idled+aborted here but stays in `ctx.agents` until the fiber is disposed. - * Since a reconnect spins up a fresh context, the lingering idle agents strand - * no work. A per-agent disposal seam (unregister on disconnect) is a follow-up - * (TODO(rfc010-agent-disposal)). + * Per-agent disposal closes the former pre-step best-effort window: the + * queue-aware `cancel()` (RFC 011) drops a turn about to start, so a queued- + * but-not-yet-running prompt never runs after teardown. A bare client + * disconnect (resolves `conn.closed` WITHOUT disposing the fiber) thus leaves + * NO registered agent and NO session-store entry — not an idled-but-still- + * registered one. When the fiber IS disposed (whole-context or an ACP-only HMR + * `acpFiber.dispose()`), this same memoized teardown runs first; the factory's + * register+start+session effects are ALSO bound to the bridge fiber (the + * factory is reached through this bridge's traceable service proxy, so + * `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the + * bridge fiber), so any agent this path did not reach is still reclaimed by + * fiber disposal. */ let quiescing: Promise | undefined const quiesce = (): Promise => { @@ -650,8 +664,12 @@ export function apply(ctx: Context, config: AcpConfig): void { quiescing = (async () => { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') - rec.agent.abort('disposed') - await rec.agent.whenIdle() + // Per-agent dispose (the AgentHandle disposer): unregister this agent, + // stop its loop with the queue-aware cancel, await quiescence (the loop + // exit + final flush), and remove its session — so a bare client + // disconnect leaves NO registered agent and NO session-store entry, not + // just an idled-but-still-registered one. + await rec.dispose() })) })() return quiescing diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 45e351ced5..6ff89b400e 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { makeBridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { let storageDir: string @@ -82,10 +83,11 @@ describe('acp bridge — disposal & HMR safety', () => { await harness.dispose() }) - it('a client disconnect mid-prompt tears the session down to quiescence', async () => { + it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { // The ACP transport closes (editor quits) while a turn runs. The bridge must - // settle the in-flight prompt cancelled and abort+drain the agent rather - // than leaving an orphaned running agent whose updates are swallowed. + // settle the in-flight prompt cancelled and DISPOSE the agent (PR D's + // per-agent AgentHandle teardown) rather than leaving an orphaned running — + // or even idled-but-still-registered — agent whose updates are swallowed. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -96,13 +98,25 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Sever the transport — the bridge's conn.closed teardown runs and drives - // the agent to quiescence on its OWN (assert before any dispose() runs). + // Sever the transport — the bridge's conn.closed teardown runs and drives the + // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() - expect(agent.status).toBe('idle') + // The agent's loop has stopped: status `disposed`. + expect(agent.status).toBe('disposed') - await harness.dispose() // idempotent with the close teardown + // Await the bridge teardown to completion WITHOUT tearing down the root + // agents/sessions services (so we can still query them). acpFiber.dispose() + // invokes the SAME memoized quiesce() the disconnect started and awaits its + // promise — which resolves only after every rec.dispose() (loop exit + + // session removal) has finished, closing the whenIdle()/owned.dispose() + // microtask race. The AgentHandle dispose has run: the agent is unregistered + // and its session removed from the store, not merely idled (the old + // behavior). The services live on the root ctx, so they survive this. + await harness.acpFiber.dispose() + expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.sessions.get(sessionId)).toBeUndefined() + await harness.dispose() }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { @@ -140,4 +154,61 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 10)) expect(harness.updates.length).toBe(before) }) + + it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { + // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, + // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire + // through the still-attached `session.onAppend` → `session/event`), and only + // THEN detach onAppend + remove the session. If the order were inverted + // (detach first), the closing events would never reach persistence. Drive a + // CLEAN turn to completion, dispose JUST the bridge, then re-load the + // persisted log from disk and assert the closing turn/end is on disk — the + // world, not the agent's self-report. + const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + const liveEvents = harness.ctx.agents.get(sessionId)!.session.events.length + expect(liveEvents).toBeGreaterThan(0) + + // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). + await harness.acpFiber.dispose() + expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + + // Re-load the session from disk: every live event (incl. the closing + // turn/end) was flushed before the session was detached. + const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) + expect(reloaded.events.length).toBe(liveEvents) + const last = reloaded.events.at(-1)! + expect(last.type).toBe('turn/end') + await harness.dispose() + }) + + it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { + // The factory returns a per-agent AgentHandle whose dispose() tears down + // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // directly through the registry factory (the same path the ACP bridge uses), + // dispose one handle, and assert the other survives, registered and + // queryable, with its session still in the store. + const harness = await makeBridgeHarness({ storageDir, script: [] }) + const handleA = harness.ctx.agents.create({ + agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' }, + }) + const handleB = harness.ctx.agents.create({ + agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' }, + }) + expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent) + expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + + await handleA.dispose() + // A is gone — unregistered AND its session removed from the store. + expect(harness.ctx.agents.get('sib-a')).toBeUndefined() + expect(harness.ctx.sessions.get('sib-a')).toBeUndefined() + expect(handleA.agent.status).toBe('disposed') + // B is wholly unaffected. + expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + expect(harness.ctx.sessions.get('sib-b')).toBeDefined() + expect(handleB.agent.status).not.toBe('disposed') + await harness.dispose() + }) }) diff --git a/packages/acp/tests/edges.spec.ts b/packages/acp/tests/edges.spec.ts index 9484368322..b9e2377908 100644 --- a/packages/acp/tests/edges.spec.ts +++ b/packages/acp/tests/edges.spec.ts @@ -25,7 +25,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const foreign = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) + const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md index e5e3a03d2d..5d79d90147 100644 --- a/packages/agent-loop/README.md +++ b/packages/agent-loop/README.md @@ -12,8 +12,10 @@ This is the only package in the harness that contains concrete loop logic. Every `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? })` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? })` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). +- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. + +The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown. ### Injected services diff --git a/packages/agent/README.md b/packages/agent/README.md index d815761ae4..846ab17444 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -17,8 +17,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Agent` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. + +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle. ### Events From a53a56ff482aebb91778ed4f0f497605d5ee18d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:12:29 +0800 Subject: [PATCH 09/87] fix(agent-loop): fold session lifecycle into the agent effect for ordered teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stronger durability test (dispose MID-turn, then re-load from disk) caught that the original two-sibling-effect design dropped the loop's closing `turn/end` on the bare fiber-dispose path: a fiber unload disposes sibling effects CONCURRENTLY (`Promise.all`, vendor/cordis/fiber.ts), so the session-create effect detached `onAppend` racing the loop's final `session/flush` — the re-loaded log showed crash-recovery's synthetic `interrupted` closer instead of the real `disposed` reason. The disconnect path happened to work (only `quiesce()` ran), but the contract must hold uniformly. Fix: fold the session lifecycle INTO the agent's single composite effect. `SessionStore` now exposes `prepare` (validate + construct, no store entry), `enter` (attach onAppend + store, returns detach), and `announce` (emit session/created), replacing the sibling-effect `createOwned`. `AgentLoop.start` builds ONE effect that yields, in order: session-detach, register, then stop-and-`await agent.done`. LIFO disposal runs them as an ORDERED chain (the runtime awaits each disposer's promise before the next), so the loop is stopped and awaited to exit — its closing flush captured through the still- attached onAppend — BEFORE the session detaches, whether the trigger is the handle's dispose() OR a fiber unload. The config path uses prepare()+start too, so it gets the same ordered teardown. All three factory entrypoints now funnel through the one composite builder. The mid-turn durability test asserts the REAL `disposed` reason lands on disk (not a recovered `interrupted` substitute), proving the closing event was captured rather than reconstructed. --- packages/acp/tests/dispose.spec.ts | 37 ++++++++++ packages/agent-loop/src/index.ts | 110 +++++++++++++++-------------- packages/session/src/index.ts | 103 +++++++++++++++++---------- 3 files changed, 158 insertions(+), 92 deletions(-) diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 6ff89b400e..72e7ba42a9 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -184,6 +184,43 @@ describe('acp bridge — disposal & HMR safety', () => { await harness.dispose() }) + it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { + // The teardown-order contract only earns its keep when the closing events are + // produced BY the dispose itself. Here the model stream HANGS, so the turn is + // still open when teardown runs: the composite agent effect stops the loop, + // the loop unwinds and appends `turn/end {disposed}` + runs its final + // `session/flush` — all while `onAppend` is still attached (the session + // detach is the LAST disposer in the same effect's LIFO chain) — and only + // THEN is the session detached. If the order were inverted (or the session + // were a racing SIBLING effect), the abort-produced `turn/end` would never + // reach disk and a re-load would instead show crash-recovery's synthetic + // `interrupted` closer. Re-load from disk and assert the REAL `disposed` + // reason landed — proving the loop's own closing event was captured, not a + // recovered substitute. + const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(sessionId)! + void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) + await new Promise(r => setTimeout(r, 30)) + expect(agent.status).toBe('running') + // The turn is OPEN in the log (turn/start appended, no turn/end yet). + const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length + + // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered + // teardown (the composite effect runs its disposer chain as a unit). + await harness.acpFiber.dispose() + expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + + // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not + // self-report) — NOT a crash-recovery `interrupted` substitute. + const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) + const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end') + expect(persistedTurnEnds.length).toBe(openTurnEnds + 1) + expect(persistedTurnEnds.at(-1)!.data.reason).toMatchObject({ kind: 'disposed' }) + await harness.dispose() + }) + it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { // The factory returns a per-agent AgentHandle whose dispose() tears down // EXACTLY that agent + its session — RFC 011 isolation. Create two agents diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index 7cc90ba3da..3963b24594 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -120,10 +120,11 @@ export class AgentLoop extends Service implements AgentFactory { */ create(id: string, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) - // Config/programmatic path: the session is owned by THIS fiber (the plain - // create()), so disposing the AgentLoop/caller fiber removes it. No - // AgentHandle is needed — the register+start effect is fiber-owned too. - const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} }) + // Config/programmatic path: prepare the session and let start() fold its + // lifecycle into the agent's composite effect (so a fiber unload tears the + // session + agent down as one ordered chain, capturing the loop's closing + // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. + const session = this.ctx.sessions.prepare(`${id}-session-${randomUUID()}`, { meta: {} }) const { agent } = this.start(AgentId(id), options, session) return agent } @@ -136,12 +137,12 @@ export class AgentLoop extends Service implements AgentFactory { * an {@link AgentHandle} the owner disposes to tear down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { - // Check the agent id BEFORE creating the session: register() would reject a - // duplicate id only AFTER sessions.create(), leaving an orphaned live - // session (and lazy persistence state) that blocks reuse of that id. + // Check the agent id BEFORE preparing the session: register() would reject a + // duplicate id only AFTER the session enters the store, leaving an orphaned + // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) - const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned) + const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) + return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) } /** @@ -193,15 +194,16 @@ export class AgentLoop extends Service implements AgentFactory { const { meta, events } = await persistence.load(SessionId(options.resumeSessionId)) // Re-check the agent id AFTER the await: the pre-load check above can go // stale while load() is pending (a concurrent resume/create may register the - // same id). Re-checking immediately before sessions.create() keeps the + // same id). Re-checking immediately before prepare()/start keeps the // "no orphaned session on a duplicate id" guarantee under concurrency. this.assertAgentIdFree(options.agentId) // Reconstruct the live session with the FULL persisted header (createdAt, // cwd, lineage) so resume preserves identity, not just the cwd. The seed // events make lastTurnNumber/deriveMessages continue; the backend already // has state (cursor) from the load above, so onCreated is a no-op and the - // seed is not re-persisted. - const owned = this.ctx.sessions.createOwned(options.resumeSessionId, { + // seed is not re-persisted. prepare() (not create()) so the session + // lifecycle folds into the agent's composite effect (ordered teardown). + const session = this.ctx.sessions.prepare(options.resumeSessionId, { seed: events, meta: { createdAt: meta.createdAt, @@ -209,14 +211,14 @@ export class AgentLoop extends Service implements AgentFactory { ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, }, }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned) + return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) } /** - * Reject a duplicate agent id BEFORE any session is created, so a failed - * factory call never leaves an orphaned live session (and lazy persistence - * state) behind. `register()` enforces the same uniqueness, but only after - * `sessions.create()` has already run. + * Reject a duplicate agent id BEFORE the session is entered into the store, so + * a failed factory call never leaves an orphaned live session (and lazy + * persistence state) behind. `register()` enforces the same uniqueness, but + * only after the session has already entered the store. */ private assertAgentIdFree(id: string): void { if (this.ctx.agents.get(id) !== undefined) { @@ -225,53 +227,55 @@ export class AgentLoop extends Service implements AgentFactory { } /** - * Shared: construct a ReactLoopAgent, register it, and start its loop. The - * register + loop-stop disposers live in ONE generator effect so they run - * LIFO on dispose (the loop-stop disposer — yielded last — runs first, then - * the registry unregister), so a throwing stop() cannot leak the registry - * entry. Returns the agent plus the effect's disposer (`disposeAgent`); the - * effect is owned by the caller fiber, so disposing that fiber also tears the - * agent down — the disposer is for an OWNER that needs to tear down ONE agent. + * Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered) + * session, then build the ONE composite effect that owns the whole agent + * lifecycle — session entry, registry registration, and the loop. Keeping all + * three in a SINGLE effect (not sibling effects) is load-bearing: a fiber + * unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would + * race the session detach against the loop's closing flush and drop the + * closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO + * chain — the runtime awaits each disposer's returned promise before the next: + * + * yield session-detach (disposed LAST — detach onAppend + remove entry) + * yield register (disposed 2nd — unregister) + * yield stop-and-drain (disposed FIRST — request loop stop, await agent.done) + * + * So on teardown: the loop is stopped and AWAITED to exit (its final + * `session/flush` + `turn/end` fire through the still-attached `onAppend`), + * THEN the agent is unregistered, THEN the session is detached — capturing the + * closing events before detach, whether the trigger is the handle's `dispose()` + * OR a fiber unload. Rollback safety: each yield runs before the next mutation, + * so a throwing `session/created`/`agent/created` listener unwinds the + * already-yielded disposers instead of leaking. + * + * Returns the agent plus the composite effect's disposer (`disposeAgent`). */ private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise } { const agent = new ReactLoopAgent(this.ctx, id, options, session) const dispose = this.ctx.effect(function* (this: AgentLoop) { + yield this.ctx.sessions.enter(session) + this.ctx.sessions.announce(session) yield this.ctx.agents.register(agent) - yield agent.start() + const stop = agent.start() + // Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's + // actual exit so its closing flush lands while onAppend (yielded above, + // disposed later) is still attached. + yield async () => { stop(); await agent.done } }.bind(this), 'agentLoop.start()') return { agent, disposeAgent: async () => { await dispose() } } } /** - * Build an {@link AgentHandle} for an OWNED session + agent. The handle's - * `dispose()` tears down exactly this agent in the order durability requires: - * - * 1. run `disposeAgent` — the register+start effect's disposer. LIFO runs - * `agent.start()`'s (synchronous) disposer first: it sets `disposed`, - * aborts the in-flight step, and unblocks the loop's idle wait. Then the - * registry unregister runs. The loop has NOT necessarily exited yet — the - * start disposer only REQUESTS exit, it does not await it. - * 2. `await agent.done` — the loop-exit promise. The loop unwinds and runs - * its final `session/flush` + `turn/end`, delivered through the still- - * attached `session.onAppend` → `session/event`, so persistence captures - * the closing events. Only now is the agent truly quiescent. - * 3. run the session disposer — detach `onAppend` and remove the store - * entry. Done LAST so step 2's final flush is not dropped. + * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The + * handle's `dispose()` just runs the composite effect's disposer (see + * {@link start}) — which stops the loop, awaits its exit (final flush + * captured), unregisters the agent, and detaches the session, in that order. + * The same composite effect is what a fiber unload disposes, so both teardown + * triggers honor the ordering identically. */ - private startOwned( - id: AgentId, - options: AgentOptions, - owned: { session: Session; dispose: () => Promise }, - ): AgentHandle { - const { agent, disposeAgent } = this.start(id, options, owned.session) - return { - agent, - dispose: async () => { - await disposeAgent() // stop the loop (sync) + unregister - await agent.done // wait for the loop to actually exit (final flush captured) - await owned.dispose() // detach onAppend + remove the session store entry - }, - } + private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle { + const { agent, disposeAgent } = this.start(id, options, session) + return { agent, dispose: disposeAgent } } } diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 05fdb0c83e..8d1471d5f3 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -219,36 +219,48 @@ export class SessionStore extends Service { } /** - * Create a session. `options.seed` populates the session with a copy of - * those events (replay/fork); `options.meta` attaches creation metadata - * (validated absolute `cwd`, `parentSession` lineage) as the immutable - * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). The - * session is a Cordis effect: disposing the calling fiber stops event - * notification and removes the session from the store. + * Create a session owned by the calling fiber: disposing that fiber stops + * event notification and removes the session from the store. `options.seed` + * populates the session with a copy of those events (replay/fork); + * `options.meta` attaches creation metadata (validated absolute `cwd`, + * `parentSession` lineage) as the immutable {@link SessionHeader} (the store + * fills `version`/`id`/`createdAt`). + * + * For an agent whose session must be torn down IN ORDER with its loop (so the + * loop's final flush is captured before `onAppend` detaches), do NOT use this + * — fold the session lifecycle into the agent's own effect via + * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s + * `startOwned`). * * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ create(id?: string, options?: CreateSessionOptions): Session { - // Discard the store-removal disposer: a plain create() is owned by the - // calling fiber (disposing the fiber removes the session). An owner that - // needs to remove ONE session independently uses createOwned(). - return this.createOwned(id, options).session + const session = this.prepare(id, options) + // Single effect owned by the calling fiber. Yield the detach BEFORE + // announcing so a throwing `session/created` listener rolls the attach back + // (the generator effect disposes already-yielded disposers on a throw) + // instead of leaking the store entry + onAppend. + this.ctx.effect(function* (this: SessionStore) { + yield this.enter(session) + this.announce(session) + }.bind(this), 'sessions.create()') + return session } /** - * Like {@link create}, but ALSO returns the disposer for the session's - * store-removal effect — so an owner can remove exactly THIS session (detach - * `onAppend`, delete the store entry) without disposing the whole fiber. + * Build a session WITHOUT entering it into the store — validate the id/cwd and + * construct the {@link Session} (with its immutable {@link SessionHeader}). + * Pairs with {@link enter} + {@link announce}: a caller that owns a composite + * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE + * effect so a fiber unload tears the session + agent down as a single ORDERED + * chain rather than as racing sibling effects — which would detach `onAppend` + * before the loop's closing `session/flush`, dropping the closing events. * - * Used by the agent factory's {@link AgentHandle} teardown: an owned agent's - * `dispose()` stops the loop, awaits quiescence, unregisters the agent, and - * THEN runs this session disposer — so the loop's final `session/flush` - * (delivered via `onAppend` → `session/event`) is captured before `onAppend` - * is detached. The disposer is async (a cordis effect disposer) to compose - * with the agent teardown's promise chain. + * @throws if a session with `id` already exists, or if `meta.cwd` is a + * non-absolute path. */ - createOwned(id?: string, options?: CreateSessionOptions): { session: Session; dispose: () => Promise } { + prepare(id?: string, options?: CreateSessionOptions): Session { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) const cwd = options?.meta?.cwd @@ -262,25 +274,38 @@ export class SessionStore extends Service { ...cwd !== undefined ? { cwd } : {}, ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, } - const session = new Session(sessionId, options?.seed, header) - const dispose = this.ctx.effect(function* (this: SessionStore) { - session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } - this.store.set(sessionId, session) - // Yield the rollback BEFORE emitting `session/created`: a generator - // effect collects each yielded disposer before the next step runs, so a - // throwing `session/created` listener detaches onAppend and removes the - // store entry instead of leaking them (a leak would wedge the - // already-exists check until restart). The duplicate throw above fires - // before any mutation — it leaks nothing. - yield () => { - session.onAppend = undefined - this.store.delete(sessionId) - } - this.ctx.emit('session/created', session) - }.bind(this), 'sessions.create()') - // ctx.effect's disposer returns Promise; normalize to an always-async - // disposer for the owner. - return { session, dispose: async () => { await dispose() } } + return new Session(sessionId, options?.seed, header) + } + + /** + * Enter a {@link prepare}d session into the store: wire `onAppend` → + * `session/event` and add it to the store. Returns the DETACH disposer + * (`onAppend = undefined` + store removal). Does NOT emit `session/created` — + * the caller yields this disposer inside its effect and THEN calls + * {@link announce}, so a throwing `session/created` listener rolls the attach + * back instead of leaking it. + * + * The id was already validated by {@link prepare}, which runs in the SAME + * synchronous sequence as `enter` (a config/factory caller does + * `prepare()` → `ctx.effect(generator)`, and a synchronous generator effect + * iterates inline — no await between them), so no concurrent create can claim + * the id in the gap. `enter` therefore does not re-check; it is not a public + * reservation primitive. + */ + enter(session: Session): () => void { + session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } + this.store.set(session.id, session) + return () => { + session.onAppend = undefined + this.store.delete(session.id) + } + } + + /** 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}). */ + announce(session: Session): void { + this.ctx.emit('session/created', session) } get(id: string): Session | undefined { From 7a94d36c46e4af9a894bf244bf753bb39fbbb176 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:12:52 +0800 Subject: [PATCH 10/87] =?UTF-8?q?docs(dsh-code-review):=20test=20sufficien?= =?UTF-8?q?cy=20=E2=80=94=20real=20usage,=20not=20just=20100%=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the "Test quality" reviewer check: 100% coverage proves lines ran, not that the feature works the way it ships. Judge sufficiency on two axes — would the test fail on a regression, and does it exercise the REAL thing (genuine collaborator, real entry path, verify the world) rather than faking inputs just enough to cover every line. Call out the specific trap of a happy-path test that hits a line whose PURPOSE is a mid-flight/error/recovery scenario it never actually drives — the exact gap a clean-turn "durability" test would miss. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 8d0792624c..ac59587e4d 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -42,7 +42,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.` (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. -- **Test quality.** A test that passes but asserts the wrong thing is worse than none. Check that new tests would actually fail if the behavior regressed, and that they exercise the contract (events fired, disposal reached) rather than restating the implementation. +- **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/2026-06-19-acp-snapshot-tests.md](../../../docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md). - **Intent and contracts.** Does the change do what the PR says, and honor the documented contract on *both* sides of every seam it touches (see AGENTS.md "Honor cross-seam contracts on BOTH sides")? From 5a5b7d19c3580af94025709995e3f0d816c315ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:47:24 +0800 Subject: [PATCH 11/87] fix(agent): contain a throwing agent/disposed listener in the register disposer (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex found a real teardown-leak (A): the AgentHandle's composite effect runs its disposers as a `.then()` chain, and the register disposer emitted `agent/disposed` UNCONTAINED. A throwing listener rejected the chain, skipping the LATER session-detach disposer — stranding the session in the store with `onAppend` attached (a leak AND a durability hole, since the new composite design relies on detach running). Verified by tracing fiber.ts:299-301 (`task = task.then(dispose)`) against the yield order in AgentLoop.start. Wrap the disposer's `agent/disposed` emit in try/catch + logger.warn (the store entry is already removed before the emit — the useful state is captured — so logging and continuing is correct, mirroring the guarded `agent/status` emit in ReactLoopAgent). The sibling `agent/created` emit stays uncontained on purpose: its throw is MEANT to propagate and roll the registration back. Regression test (acp dispose.spec): register a throwing `agent/disposed` listener, drive a clean turn, dispose, assert the session was STILL removed. Confirmed it FAILS without the guard (the throw escapes dispose and detach is skipped) and passes with it. Also (B): document the new `prepare`/`enter`/`announce` ordered-teardown lifecycle primitives in the dsh-session README (they are public cross-package methods now consumed by dsh-agent-loop). --- packages/acp/tests/dispose.spec.ts | 25 +++++++++++++++++++++++++ packages/agent/src/index.ts | 17 ++++++++++++++++- packages/session/README.md | 10 ++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 72e7ba42a9..d3af9ccd6c 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -248,4 +248,29 @@ describe('acp bridge — disposal & HMR safety', () => { expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() }) + + it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { + // The AgentHandle teardown folds session-detach, register, and loop-stop + // into ONE composite effect whose disposers run as a `.then()` chain. The + // register disposer emits `agent/disposed`; if a listener throws and the + // emit is UNCONTAINED, the rejected chain skips the LATER session-detach + // disposer — stranding the session in the store with `onAppend` attached (a + // leak AND a durability hole, since the new design relies on detach + // running). The emit must be contained. Register a throwing listener, drive + // a clean turn, dispose, and assert the session was STILL removed. + const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) + harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) + const handle = harness.ctx.agents.create({ + agentId: 'guard-a', sessionId: 'guard-a', agentOptions: { model: 'mock' }, + }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await handle.agent.whenIdle() + expect(harness.ctx.sessions.get('guard-a')).toBeDefined() + + // Dispose: the throwing listener must NOT break the chain before detach. + await handle.dispose() + expect(harness.ctx.agents.get('guard-a')).toBeUndefined() + expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran + await harness.dispose() + }) }) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 2063812963..cd66156052 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -164,7 +164,22 @@ export class AgentRegistry extends Service { // The duplicate throw above fires before any mutation — it leaks nothing. yield () => { this.store.delete(agent.id) - this.ctx.emit('agent/disposed', agent) + // CONTAIN a throwing `agent/disposed` listener: this disposer runs as + // one link in the owning fiber/effect's disposal chain, and Cordis + // chains later disposers with `task.then(next)` — so an UNCAUGHT throw + // here rejects the chain and SKIPS every later disposer. When this + // registration shares a composite effect with a session (the agent + // factory's `AgentLoop.start`, where the session-detach disposer runs + // AFTER this one), a swallowed-less throw would strand the session in + // the store with `onAppend` attached — a leak AND a durability hole. + // The store entry is already removed above (the useful state), so + // logging the listener bug and continuing is correct (mirrors the + // guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent). + try { + this.ctx.emit('agent/disposed', agent) + } catch (error: unknown) { + this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`) + } } this.ctx.emit('agent/created', agent) }.bind(this), 'agents.register()') diff --git a/packages/session/README.md b/packages/session/README.md index bdd825e2e7..dabe316a28 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -12,6 +12,16 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.get(id: string): Session | undefined` - `ctx.sessions.list(): Session[]` +#### Advanced: ordered-teardown lifecycle primitives + +`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: + +- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`. +- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check. +- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session. + +`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload. + ### Events | Event | Mode | Purpose | From d1b7c3bf95f4dfe2a1231e55e971ec76907c817f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:12:49 +0800 Subject: [PATCH 12/87] feat(bash): add an opaque owner token to the executor seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background-task ownership needs a stable home that survives a consumer HMR reload. Add an optional `owner?: string` to `BashExecRequest` and a required-but-nullable `owner: string | undefined` to the resolved `BashExecSpec` (mirroring how `workdir`/`timeoutMs` are required on the spec — a forgotten owner is a visible `undefined`, never a silently-absent property that yields an unowned, cross-session-readable task). `resolve()` carries it through. Expose the stored token via a new `BashExecutor.ownerOf(id): string | undefined` seam (ONE read path — not also on the public `BashTask`). The executor stores and returns the token verbatim and NEVER interprets it: the access POLICY lives in the consumer (`dsh-tool-bash`). `bash-local` stores `owner` on its `TrackedTask` and implements `ownerOf`; unknown-id and known-but-ownerless both read as `undefined`. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it survives a `tool-bash` HMR reload. Updates the StubExecutor seam test and the bash/bash-local READMEs. --- ...-18-agent-lifecycle-and-ownership-seams.md | 26 ------------------- packages/bash-local/README.md | 2 +- packages/bash-local/src/index.ts | 12 +++++++++ packages/bash/README.md | 3 ++- packages/bash/src/index.ts | 15 +++++++++++ packages/bash/src/types.ts | 18 +++++++++++++ packages/bash/tests/service.spec.ts | 7 +++++ 7 files changed, 55 insertions(+), 28 deletions(-) delete mode 100644 docs/rfc/proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md diff --git a/docs/rfc/proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md deleted file mode 100644 index ea98277d0e..0000000000 --- a/docs/rfc/proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ /dev/null @@ -1,26 +0,0 @@ -# RFC: Agent lifecycle and ownership seams - -Status: proposed - -## Problem - -Several ACP and tool-bash limitations are symptoms of the same missing seam: plugins can create or resume agents through `ctx.agents`, but they cannot own and dispose one agent independently, and long-running bash tasks carry no stable owner in the executor itself. ACP currently aborts and awaits agents on disconnect, but cannot unregister just that session's agent; `session/cancel` cannot cancel queued-but-not-yet-started work; and `tool-bash` keeps task ownership in a plugin-local `Map`, so an HMR reload can make an old task look unowned. - -## Proposal - -Add explicit lifecycle ownership to the agent factory and explicit ownership metadata to background tasks. - -1. `ctx.agents.create/resume` should return an `AgentHandle` (or add an adjacent method) that exposes the `Agent` plus an async disposer. The disposer unregisters the agent, aborts queued/running work, and resolves only when the driver loop reaches quiescence. -2. Add a queue-aware cancel primitive to the `Agent` interface. It must clear queued work that has not started, abort the current step if one exists, and make `whenIdle()` wait for the post-cancel quiescent state. ACP `session/cancel` and bridge teardown then become honest cancellation, not best-effort pre-step cancellation. -3. Move background task ownership into the bash seam. `BashExecSpec` or `BashTask` should carry a stable owner token, preferably the session id rather than the `Agent` object identity. `bash_output`/`bash_kill` then ask the executor for ownership rather than relying on a `tool-bash` instance-local map. - -## Acceptance Criteria - -- ACP disconnect/session close leaves no registered agent for that session, even when `session/load` races teardown. -- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. -- A `tool-bash` HMR reload does not make an existing background task readable or killable by a different session. -- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. - -## Risks - -This touches public interfaces (`Agent`, `AgentFactory`, and the bash seam), so it should not be smuggled into a local ACP patch. The compatibility trap is preserving the simple synchronous `Agent.send()` ergonomics while adding a robust async lifecycle path for owners that need it. diff --git a/packages/bash-local/README.md b/packages/bash-local/README.md index d8062bc6ff..016f57d2a9 100644 --- a/packages/bash-local/README.md +++ b/packages/bash-local/README.md @@ -22,7 +22,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **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. - **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** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. -- **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. +- **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. ## Sandboxing diff --git a/packages/bash-local/src/index.ts b/packages/bash-local/src/index.ts index 7320276a1a..df6e2285a9 100644 --- a/packages/bash-local/src/index.ts +++ b/packages/bash-local/src/index.ts @@ -49,6 +49,8 @@ interface TrackedTask extends BashTask { /** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */ stdoutOffset: number stderrOffset: number + /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */ + owner: string | undefined } /** @@ -114,6 +116,9 @@ export class LocalBashExecutor extends BashExecutor { workdir: request.workdir ?? this.config.cwd ?? process.cwd(), timeoutMs, ...request.signal ? { signal: request.signal } : {}, + // Carry the owner through verbatim (required-but-nullable on the spec): + // the executor never interprets it — the consumer's access policy does. + owner: request.owner, } } @@ -149,6 +154,7 @@ export class LocalBashExecutor extends BashExecutor { status: 'running', exitCode: null, signal: null, + owner: spec.owner, running, stdoutOffset: 0, stderrOffset: 0, @@ -174,6 +180,12 @@ export class LocalBashExecutor extends BashExecutor { return this.tasks.get(id) } + ownerOf(id: string): string | undefined { + // Unknown id and known-but-ownerless both read as undefined — the consumer + // treats undefined as "open" and a truly unknown id fails at readOutput/kill. + return this.tasks.get(id)?.owner + } + list(): BashTask[] { return [...this.tasks.values()] } diff --git a/packages/bash/README.md b/packages/bash/README.md index 8de529fee1..ce8816dee7 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -19,6 +19,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su | `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. | | `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). | | `get(id)` / `list()` | Task lookup. | +| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. | | `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. | | `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | | `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. | @@ -27,4 +28,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, signal?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?) before execution; `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. diff --git a/packages/bash/src/index.ts b/packages/bash/src/index.ts index e22aad5ff3..f4e2d964fe 100644 --- a/packages/bash/src/index.ts +++ b/packages/bash/src/index.ts @@ -88,6 +88,21 @@ export abstract class BashExecutor extends Service { /** Look up a background task by id. */ abstract get(id: string): BashTask | undefined + /** + * The opaque OWNER token recorded for a background task at {@link start} + * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id + * OR a known-but-ownerless task. The executor stores and returns the token + * verbatim — it never interprets it; the access POLICY (who may read/kill a + * task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares + * `ownerOf(id)` to the caller's token. Collapsing unknown-id and + * known-but-unowned into the same `undefined` is fine: the consumer's access + * gate treats `undefined` as "open", and a genuinely unknown id then fails + * 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. + */ + abstract ownerOf(id: string): string | undefined + /** All tracked background tasks (insertion order). */ abstract list(): BashTask[] diff --git a/packages/bash/src/types.ts b/packages/bash/src/types.ts index cfa60ab331..e731110698 100644 --- a/packages/bash/src/types.ts +++ b/packages/bash/src/types.ts @@ -20,6 +20,15 @@ export interface BashExecRequest { timeoutMs?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Opaque OWNER token for a background task — the consumer's isolation key + * (the tool layer passes the owning agent's `session.header.id`). The + * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; + * the executor itself NEVER interprets it (no access policy lives in the + * seam — that is the consumer's job). Absent for foreground runs and for an + * ownerless background start (a non-agent caller). + */ + owner?: string | undefined } /** @@ -36,6 +45,15 @@ export interface BashExecSpec { timeoutMs: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` + * being required on the resolved spec): {@link BashExecutor.resolve} carries + * the request's `owner` through, defaulting a missing one to `undefined`. A + * required field makes a forgotten owner a VISIBLE `undefined` rather than a + * silently-absent property that yields an unowned (cross-session-readable) + * task. `start()` stores it; `run()` (foreground) ignores it. + */ + owner: string | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ diff --git a/packages/bash/tests/service.spec.ts b/packages/bash/tests/service.spec.ts index 751bd80015..4b28bb72be 100644 --- a/packages/bash/tests/service.spec.ts +++ b/packages/bash/tests/service.spec.ts @@ -6,6 +6,7 @@ import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRe /** Minimal concrete executor: records calls, lets tests drive completions. */ class StubExecutor extends BashExecutor { tasks = new Map() + private owners = new Map() resolve(request: BashExecRequest): BashExecSpec { return { @@ -13,6 +14,7 @@ class StubExecutor extends BashExecutor { workdir: request.workdir ?? '/stub', timeoutMs: request.timeoutMs ?? 1000, ...request.signal ? { signal: request.signal } : {}, + owner: request.owner, } } @@ -38,6 +40,7 @@ class StubExecutor extends BashExecutor { done: Promise.resolve(), } this.tasks.set(task.id, task) + this.owners.set(task.id, spec.owner) return task } @@ -45,6 +48,10 @@ class StubExecutor extends BashExecutor { return this.tasks.get(id) } + ownerOf(id: string): string | undefined { + return this.owners.get(id) + } + list(): BashTask[] { return [...this.tasks.values()] } From b58f1dd5c89c791690902b81a3175bb3bd23c0e5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:14:27 +0800 Subject: [PATCH 13/87] refactor(tool-bash): own background tasks by session token, not a plugin-local Map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the `taskOwner: Map` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links. --- docs/rfc/README.md | 2 +- ...-18-agent-lifecycle-and-ownership-seams.md | 40 +++++ .../proposed/2026-06-14-acp-multi-session.md | 4 +- packages/tool-bash/README.md | 4 +- packages/tool-bash/src/index.ts | 86 ++++++----- packages/tool-bash/tests/tools.spec.ts | 143 +++++++++++++----- 6 files changed, 205 insertions(+), 74 deletions(-) create mode 100644 docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 3897020434..196958a709 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -31,7 +31,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Multiplex concurrent ACP sessions over one connection](proposed/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | -| [Agent lifecycle and ownership seams](proposed/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | ## Implemented @@ -61,6 +60,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | | [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | +| [Agent lifecycle and ownership seams](implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md new file mode 100644 index 0000000000..9f4e930b44 --- /dev/null +++ b/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -0,0 +1,40 @@ +# RFC: Agent lifecycle and ownership seams + +Status: implemented + +## Problem + +Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned. + +## What was implemented + +The three seams shipped across a stacked chain of PRs (the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token), each converged independently. + +### 1. Queue-aware `Agent.cancel(reason?)` + +A new `cancel()` verb on the `Agent` interface (distinct from the narrower step-only `abort()`). It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. + +### 2. `AgentHandle` async disposer + +`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear down exactly this agent: stop its loop, `await` the loop's exit (true quiescence, not just the `disposed` status flip), unregister it, and remove its session from the store. `ctx.agents.get(id)` still returns a bare `Agent`. Config-created agents stay owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw). + +**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race the session's `onAppend` detach against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The register disposer's `agent/disposed` emit is contained (a throwing listener must not reject the chain and skip the later session detach). + +### 3. Bash owner token in the seam + +Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.session.header.id` as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.session.header.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) + +## Acceptance Criteria (met) + +- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown. +- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. +- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). +- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. + +## Seam precondition (recorded) + +The bash owner-token comparison relies on `session.header.id` being unique among live agents. The agent registry does NOT enforce this — it rejects a duplicate *agentId*, not a duplicate session id, and `createAgent` accepts an arbitrary `sessionId`. This is NOT reachable via ACP (UUID sessionId, `agentId === sessionId`, duplicate-load rejected), so it is not a live product hole, but a programmatic caller that registers two agents with the same session id would break bash isolation and mis-route the completion notice. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/impl/consumer split. + +## Notes + +This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. The simple synchronous `Agent.send()` ergonomics were preserved; the async lifecycle path is additive, for owners that need it. diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index e4d7bbbe41..8b3bcfa395 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -3,13 +3,13 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. ## Problem [ACP support](2026-06-14-acp-agent-client-protocol.md) ships with a single active session per connection: a second `session/new` is rejected. Editors expect to run several conversations over one agent subprocess — a user opens multiple threads, or a client pre-warms sessions. The single-session guard is a deliberate MVP scope cut, not an architectural limit; this RFC lifts it. -This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in [agent lifecycle and ownership seams](2026-06-18-agent-lifecycle-and-ownership-seams.md). +This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in [agent lifecycle and ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md). ## Proposal diff --git a/packages/tool-bash/README.md b/packages/tool-bash/README.md index 33ac71bc05..0a2d3dd4f4 100644 --- a/packages/tool-bash/README.md +++ b/packages/tool-bash/README.md @@ -30,7 +30,7 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[timed ### Task ownership (cross-session isolation) -The owning agent is recorded per task id at spawn and kept for the lifetime of the loaded plugin instance (it is **not** cleared on completion). `bash_output`/`bash_kill` reject a task owned by a *different* agent with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this ownership check is the fence that stops one session's agent from reading or killing another session's background task. (`XXX(tool-bash-owner-hmr)`: an independent HMR reload of this plugin starts a fresh map, so a task spawned before the reload becomes un-owned — acceptable as HMR is dev-only and the session boundary is one user's cooperative editor; a durable fix attaches ownership to the executor/task lifetime.) +The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) ## UI presentation @@ -38,7 +38,7 @@ These tools own how their calls render in a UI (an editor's tool-call card) via ## Background completion notices -When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. +When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. ## Permissions diff --git a/packages/tool-bash/src/index.ts b/packages/tool-bash/src/index.ts index c0522524f7..7302aed6f1 100644 --- a/packages/tool-bash/src/index.ts +++ b/packages/tool-bash/src/index.ts @@ -11,22 +11,23 @@ * message, which is why the tool descriptions tell the model to poll with * `bash_output`. * - * Task ownership: the owning agent is recorded per task id at spawn and kept - * for the lifetime of THIS plugin instance (it is NOT cleared on task - * completion — a finished task must stay un-readable / un-killable by a - * different agent). `bash_output`/`bash_kill` reject a task owned by a DIFFERENT - * agent (a task with no recorded owner is open to anyone). Task ids are global - * and predictable (`bash-1`, …); under multi-session ACP (RFC 011) this - * ownership check is the fence that stops one session's agent from reading or - * killing another session's background task. + * Task ownership: a background task's OWNER is an opaque token — the owning + * agent's `session.header.id` — passed to the executor at spawn + * (`resolve({ …, owner })`) and stored ON THE TASK inside the executor + * (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map. + * `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token + * and reject a task owned by a DIFFERENT session (`owner !== undefined && owner + * !== caller`); an unowned task (no token — started by a non-agent caller) is + * open to anyone. Task ids are global and predictable (`bash-1`, …); under + * multi-session ACP (RFC 011) this token check is the fence that stops one + * session's agent from reading or killing another session's background task. * - * XXX(tool-bash-owner-hmr): the ownership map is per-plugin-instance, so an - * independent HMR reload of `tool-bash` (without reloading `dsh-bash`) starts a - * fresh map and a task spawned before the reload becomes un-owned (open to any - * caller). This is acceptable today — HMR is dev-only, the ACP session boundary - * is one user's cooperative editor (not an adversarial trust boundary), and the - * executor's own disposal kills its tasks — but a durable fix would attach - * ownership to the executor/task lifetime via a `dsh-bash` seam. + * Because ownership lives on the task in the EXECUTOR (disposed with the + * `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old + * plugin-local-map gap where a reload orphaned pre-reload tasks. (The + * `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a + * completion landing during the reload gap still drops its one notice — the + * pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) * * TODO(permissions): commands run with the executor's full authority. The * permission/sandbox seam is the `tools/execute` waterfall (veto/ask) plus @@ -268,33 +269,47 @@ function statusLine(task: BashTask): string { } export function apply(ctx: Context): void { - // Owning agent per background task id, recorded at spawn. Kept for the - // lifetime of THIS plugin instance (NOT cleared on completion): a completed - // task must stay un-readable / un-killable by a DIFFERENT agent, so the - // ownership record outlives the task. Under multi-session ACP (RFC 011) this - // is the isolation fence — one session's agent must never read or kill - // another session's background task. A task with no recorded owner (started by - // a non-loop caller, `exec.agent` absent) is unowned and accessible to anyone. - // An independent `tool-bash` HMR reload resets this map — see the - // XXX(tool-bash-owner-hmr) note in the module doc. - const taskOwner = new Map() + /** + * The caller's owner TOKEN — the owning agent's `session.header.id`, or + * `undefined` for a non-agent caller. Read `session.header.id` (NOT + * `session.id`): every other subsystem keys off the header id (the ACP bridge, + * both persistence backends), and the sibling `resolveWorkdir` already reads + * `session.header.cwd`, so using `session.id` here would be the asymmetry smell + * the conventions flag. The two are equal in production, but the header is the + * canonical identity. + */ + const callerToken = (exec: { agent?: Agent }): string | undefined => exec.agent?.session.header.id /** - * Authorize a `bash_output`/`bash_kill` call against a task's owner. Rejects - * when the task has a recorded owner and the caller is not that exact agent — - * including the conservative no-agent case (`exec.agent` absent cannot prove - * ownership of an owned task). An unowned task (no record) is allowed. + * Authorize a `bash_output`/`bash_kill` call against the task's stored owner + * token. Rejects when the task HAS an owner and it differs from the caller's + * token — using `!== undefined` semantics, NOT truthiness, so an empty-string + * token is still a real owner (never treated as unowned). An unowned task + * (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also + * `undefined` here and then fails loudly at the subsequent + * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller + * (`callerToken` undefined) cannot match an owned task and is rejected. */ const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => { - const owner = taskOwner.get(taskId) - if (owner !== undefined && owner !== exec.agent) { + const owner = ctx.bash.ownerOf(taskId) + if (owner !== undefined && owner !== callerToken(exec)) { throw new Error(`task ${taskId} belongs to another session`) } } // Background completion → inject a notice into the owning agent's session. + // Find the live agent by its session id token via the agent registry, read + // opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject): + // this listener runs from `task.done.then` on the bash fiber — a foreign + // fiber — where the `ctx.agents` property proxy would throw through the + // traceable shadow; `ctx.get(name)` is the topology-independent lookup. No + // registry mounted (`undefined`) → drop the notice. Match on + // `agent.session.header.id`, NOT the registry key: a config agent's id differs + // from its session id, and the owner token IS the session id. ctx.bash.onTaskDone((task) => { - const agent = taskOwner.get(task.id) + const ownerToken = ctx.bash.ownerOf(task.id) + if (ownerToken === undefined) return + const agent = ctx.get('agents')?.list().find(a => a.session.header.id === ownerToken) if (!agent) return try { agent.inject( @@ -348,8 +363,11 @@ export function apply(ctx: Context): void { ...exec.signal ? { signal: exec.signal } : {}, } if (args.run_in_background === true) { - const task = ctx.bash.start(ctx.bash.resolve(request)) - if (exec.agent) taskOwner.set(task.id, exec.agent) + // Stamp the owner token (the agent's session id) onto the spec so the + // executor stores it on the task — the isolation fence for bash_output/ + // bash_kill. Foreground runs pass no owner (they finish inline; nothing + // to fence). + const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) })) return [{ type: 'text', text: `started background task ${task.id}` }] } const result = await ctx.bash.run(ctx.bash.resolve(request)) diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/tool-bash/tests/tools.spec.ts index 1e75d7fb24..53e3448b13 100644 --- a/packages/tool-bash/tests/tools.spec.ts +++ b/packages/tool-bash/tests/tools.spec.ts @@ -8,6 +8,8 @@ import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { renderResult } from '@deepseek-ai/dsh-tool-bash' @@ -18,12 +20,37 @@ async function setup() { const ctx = new Context() 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(ToolBash) return ctx } +/** + * Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in + * `ctx.agents` (the completion-notice path finds the owning agent by scanning + * the registry for a matching `session.header.id`), and return it. The returned + * agent is also passed to `execute` as `exec.agent` so it owns the spawned task. + * The registration disposer is tracked so {@link unregisterFakeAgents} can drop + * it (simulating the owning session disconnecting before a task completes). + */ +const fakeAgentDisposers = new Map void)[]>() +function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { + const agent = { id: sessionId, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent + const dispose = ctx.agents.register(agent) + const list = fakeAgentDisposers.get(ctx) ?? [] + list.push(dispose) + fakeAgentDisposers.set(ctx, list) + return agent +} + +/** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */ +function unregisterFakeAgents(ctx: Context): void { + for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose() + fakeAgentDisposers.delete(ctx) +} + let callCounter = 0 function call(ctx: Context, name: string, args: unknown) { return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args }) @@ -49,6 +76,7 @@ class LossyReadBashExecutor extends BashExecutor { workdir: request.workdir ?? process.cwd(), timeoutMs: request.timeoutMs ?? 0, ...request.signal ? { signal: request.signal } : {}, + owner: request.owner, } } @@ -64,6 +92,10 @@ class LossyReadBashExecutor extends BashExecutor { return id === this.task.id ? this.task : undefined } + ownerOf(): string | undefined { + return undefined + } + list(): BashTask[] { return [this.task] } @@ -320,10 +352,13 @@ describe('background tools', () => { expect(text(result)).toMatch(pattern) }) - it('injects a completion notice into the owning agent', async () => { + it('injects a completion notice into the owning agent (found via the registry by session token)', async () => { const ctx = await setup() const inject = vi.fn() - const agent = { inject, session: { header: { version: 1, id: 'bg', createdAt: 0 } } } as unknown as import('@deepseek-ai/dsh-agent').Agent + // The notice path looks the agent up in ctx.agents by its session token, so + // the agent must be REGISTERED (not merely passed to execute). Mount a + // registry and register a fake whose session.header.id IS the owner token. + const agent = registerFakeAgent(ctx, 'bg', inject) const started = await ctx.tools.execute({ callId: CallId('call-bg'), @@ -346,10 +381,7 @@ describe('background tools', () => { it('swallows ONLY the disposed-agent inject error', async () => { const ctx = await setup() - const agent = { - inject: () => { throw new Error('agent "x" is disposed') }, - session: { header: { version: 1, id: 'bg', createdAt: 0 } }, - } as unknown as import('@deepseek-ai/dsh-agent').Agent + const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('agent "x" is disposed') }) const started = await ctx.tools.execute({ callId: CallId('call-bg2'), @@ -368,10 +400,7 @@ describe('background tools', () => { // the listener itself must have thrown rather than silently eaten it. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { - const agent = { - inject: () => { throw new Error('unexpected inject bug') }, - session: { header: { version: 1, id: 'bg', createdAt: 0 } }, - } as unknown as import('@deepseek-ai/dsh-agent').Agent + const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('unexpected inject bug') }) const started = await ctx.tools.execute({ callId: CallId('call-bg3'), @@ -390,6 +419,28 @@ describe('background tools', () => { } }) + it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => { + // A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its + // per-session agent — e.g. the ACP session disconnects and its AgentHandle + // disposes while the background task is still running. The owner token is + // still on the task, but no live agent carries it anymore, so the registry + // lookup finds nothing and the notice is dropped (no throw). + const ctx = await setup() + const inject = vi.fn() + const agent = registerFakeAgent(ctx, 'bg', inject) + const started = await ctx.tools.execute({ + callId: CallId('call-bg4'), + name: 'bash', + arguments: { command: 'true', description: 'test command', run_in_background: true }, + agent, + }) + const id = /task (bash-\d+)/.exec(text(started))![1]! + // Unregister the agent BEFORE the task completes (simulate disconnect). + unregisterFakeAgents(ctx) + await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() + expect(inject).not.toHaveBeenCalled() + }) + it('does not notify when no agent owned the task', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) @@ -403,18 +454,23 @@ describe('background task ownership (cross-session isolation)', () => { function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) { return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } - // Distinct identities — ownership is by agent object identity, not id. - const fakeAgent = () => ({ inject: () => undefined, session: { header: { version: 1, id: 'bg', createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + // Ownership is by TOKEN (session.header.id), NOT agent object identity — so + // each agent needs a DISTINCT session id, else every fake yields the same + // token and the isolation tests pass for the wrong reason (all tasks owned by + // the same token). The impl reads `session.header.id`, so the fakes MUST carry + // it. + const fakeAgent = (sessionId: string) => + ({ inject: () => undefined, session: { header: { version: 1, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent - it('rejects bash_output/bash_kill for a task owned by a DIFFERENT agent', async () => { + it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => { const ctx = await setup() - const a = fakeAgent() - const b = fakeAgent() + const a = fakeAgent('sess-a') + const b = fakeAgent('sess-b') // Agent A starts a long-running background task. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - // Agent B cannot read or kill A's task. + // Agent B (a different session token) cannot read or kill A's task. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) expect(readByB.isError).toBe(true) expect(text(readByB)).toMatch(/belongs to another session/) @@ -428,12 +484,26 @@ describe('background task ownership (cross-session isolation)', () => { expect(text(killByA)).toBe(`killed background task ${id}`) }) + it('a DIFFERENT Agent object with the SAME session token may access the task (identity no longer matters)', async () => { + // The old design fenced by Agent object identity; the token design fences by + // session.header.id. Two distinct Agent objects sharing one session token + // (e.g. an agent re-created on the same session) are now the SAME owner. + const ctx = await setup() + const a1 = fakeAgent('sess-shared') + const a2 = fakeAgent('sess-shared') // distinct object, same token + const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) + const id = /task (bash-\d+)/.exec(text(started))![1]! + const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id }) + expect(readByA2.isError).toBe(false) + await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup + }) + it('the no-agent (non-loop) caller cannot access an owned task', async () => { const ctx = await setup() - const a = fakeAgent() + const a = fakeAgent('sess-a') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - // A call with no exec.agent cannot prove ownership of an owned task. + // A call with no exec.agent has no token → cannot prove ownership of an owned task. const read = await callAs(ctx, undefined, 'bash_output', { task_id: id }) expect(read.isError).toBe(true) expect(text(read)).toMatch(/belongs to another session/) @@ -442,20 +512,20 @@ describe('background task ownership (cross-session isolation)', () => { it('an UNOWNED task (started with no agent) is accessible to anyone', async () => { const ctx = await setup() - // Started by a non-loop caller (no exec.agent) → no recorded owner. + // Started by a non-loop caller (no exec.agent) → no owner token recorded. const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! // Any agent (and the no-agent caller) may read/kill it. - const read = await callAs(ctx, fakeAgent(), 'bash_output', { task_id: id }) + const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id }) expect(read.isError).toBe(false) const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id }) expect(killed.isError).toBe(false) }) - it('the owner can still access its task AFTER it completes (owner record persists)', async () => { + it('the owner can still access its task AFTER it completes (owner token persists on the task)', async () => { const ctx = await setup() - const a = fakeAgent() - const b = fakeAgent() + const a = fakeAgent('sess-a') + const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! await ctx.bash.get(id)!.done @@ -467,12 +537,13 @@ describe('background task ownership (cross-session isolation)', () => { expect(readByA.isError).toBe(false) }) - it('documents the HMR caveat: an independent tool-bash reload resets ownership', async () => { - // The ownership map is per-plugin-instance (XXX(tool-bash-owner-hmr)). When - // ONLY tool-bash is reloaded (bash/executor + task survive), the new instance - // has an empty map, so the previously-owned task becomes unowned (open). This - // test pins that documented behavior — a regression here (e.g. an accidental - // global map) would change it. + it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => { + // The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT + // in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor + + // task survive) preserves ownership — closing the old XXX(tool-bash-owner-hmr) + // gap where the fresh map orphaned pre-reload tasks. This is the regression + // guard: an accidental return to a plugin-local map would make B accessible + // after reload, and this test would catch it. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -480,21 +551,23 @@ describe('background task ownership (cross-session isolation)', () => { ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } const fiber = await ctx.plugin(ToolBash) - const a = fakeAgent() - const b = fakeAgent() + const a = fakeAgent('sess-a') + const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! // Before reload: B is rejected (A owns it). expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true) - // Reload ONLY tool-bash; the executor and its running task survive. + // Reload ONLY tool-bash; the executor and its running task (with its owner + // token) survive. await fiber.dispose() await ctx.plugin(ToolBash) expect(ctx.bash.get(id)?.status).toBe('running') + expect(ctx.bash.ownerOf(id)).toBe('sess-a') - // After reload the fresh map has no owner → B can now access it (the caveat). - expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(false) - await callAs(ctx, b, 'bash_kill', { task_id: id }) // cleanup + // After reload, ownership is INTACT → B is STILL rejected. + expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true) + await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup }) }) From be6cf4510bd3686b036f56f409d0dc923c50a448 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:57:05 +0800 Subject: [PATCH 14/87] test(tool-bash): make the notice fake's agentId differ from its session token (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged (C) a test-sufficiency gap: the completion-notice fake had `agent.id === session.header.id`, so the notice test could not distinguish the code matching on the registry KEY (agentId) from matching on the session TOKEN (session.header.id). The production code deliberately matches on `session.header.id` because a config agent has `agentId !== sessionId` — but a same-value fake passes either way (the "hits the line but not the scenario" trap). Give the registered fake a distinct agentId (`agent-`). Verified the notice test now FAILS if the match is regressed to `a.id` and passes on `a.session.header.id` — so it actually pins the discriminating behavior. --- packages/tool-bash/tests/tools.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/tool-bash/tests/tools.spec.ts index 53e3448b13..e94cf108a9 100644 --- a/packages/tool-bash/tests/tools.spec.ts +++ b/packages/tool-bash/tests/tools.spec.ts @@ -37,7 +37,13 @@ async function setup() { */ const fakeAgentDisposers = new Map void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { - const agent = { id: sessionId, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent + // The registry KEY (agent.id) is deliberately DIFFERENT from the session + // token (session.header.id) — a config agent has `agentId !== sessionId`. The + // owner token IS the session id, so the notice path must find the agent by + // `session.header.id`, NOT the registry key. Using distinct values here makes + // the test fail if a regression matched on the wrong field (a same-value fake + // would pass either way — the "hits the line but not the scenario" trap). + const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent const dispose = ctx.agents.register(agent) const list = fakeAgentDisposers.get(ctx) ?? [] list.push(dispose) From 6a1e381e38c6683d59dff18271efd5951cd78846 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:51:32 +0800 Subject: [PATCH 15/87] fix(agent): carry cancel(reason) through the marker-only windows (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer found that `cancel(reason)` only preserved the caller's reason when an active AbortController observed it (the mid-step path, via `abort.signal.reason`). The marker-only windows (step-start at loop.ts and the continuation gate) hardcoded `reason: 'cancelled'`, so the logged `turn/end` reason was race-dependent on WHERE the cancel landed and the public `cancel(reason?)` parameter was half-effective. Capture the resolved reason (`reason ?? 'cancelled'`) on the agent when the marker is armed, expose it on the LoopHandle as `cancelReason()`, and use it in both marker branches so a turn dropped without a live controller records the SAME `{kind:'aborted', reason}` the mid-step path produces. The two existing window tests asserted `reason: 'cancelled'` while passing `'from turn-start'` / `'from continuation'` — they documented the bug. Updated both to assert the caller's reason (behavior + test changed together, per AGENTS.md "tests document behavior, not golden truth"). Also fixes two stale docs the PR's contract change left behind: the module-level ACP mapping comment and `codec.ts` both still said `session/cancel -> agent.abort()`. --- packages/acp/src/codec.ts | 2 +- packages/acp/src/index.ts | 4 +++- packages/agent-loop/src/agent.ts | 16 ++++++++++++++++ packages/agent-loop/src/loop.ts | 12 ++++++++++-- packages/agent-loop/tests/cancel.spec.ts | 11 +++++++---- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/acp/src/codec.ts b/packages/acp/src/codec.ts index dd28bc5ae6..5f5a53f529 100644 --- a/packages/acp/src/codec.ts +++ b/packages/acp/src/codec.ts @@ -26,7 +26,7 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr * * - `completed` → `end_turn` (the model chose to stop) * - `max-tokens` → `max_tokens` (cut off at the output-token ceiling) - * - `aborted` → `cancelled` (an `agent.abort()`, e.g. from `session/cancel`) + * - `aborted` → `cancelled` (a step abort or a queue-aware `agent.cancel()`, e.g. from `session/cancel`) * - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the * `session/prompt` RPC on an error turn BEFORE calling this, so * a client sees a JSON-RPC error, not a stop reason — see diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 040f4b10d1..91553669ca 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -13,7 +13,9 @@ * - `session/load` → `ctx.agents.resume(...)` then replay the event log * - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn * that ends in `error` rejects the RPC) - * - `session/cancel` → `agent.abort()` + settle the in-flight prompt + * - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a + * running step, clears queued + steering work, and drops a + * turn about to start) + settle the in-flight prompt * * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an diff --git a/packages/agent-loop/src/agent.ts b/packages/agent-loop/src/agent.ts index 72bc1b0455..df25af1c11 100644 --- a/packages/agent-loop/src/agent.ts +++ b/packages/agent-loop/src/agent.ts @@ -34,6 +34,17 @@ export class ReactLoopAgent implements Agent { * leave it set to wrongly drop a later prompt. */ private cancelRequested = false + /** + * The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`), + * read by the driver loop's marker branches so a turn dropped in a + * marker-only window (pre-step / continuation, where no `AbortController` + * carries the reason) ends with the SAME `{kind:'aborted', reason}` the + * mid-step abort path produces from `abort.signal.reason`. Without this the + * caller's `cancel(reason)` would be silently replaced by the literal + * 'cancelled' whenever the cancel landed outside a running step — making the + * logged reason race-dependent and the public `reason?` param half-effective. + */ + private cancelReason = 'cancelled' private disposed: Promise private resolveDisposed!: () => void /** Resolves when the driver loop has fully exited (tests/disposal). */ @@ -196,6 +207,10 @@ export class ReactLoopAgent implements Agent { // precisely to cover it. if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) { this.cancelRequested = true + // Capture the resolved reason for the marker-only windows (pre-step / + // continuation). The mid-step path reads it from abort.signal.reason + // below; the marker path reads it via the LoopHandle's cancelReason(). + this.cancelReason = reason ?? 'cancelled' } // Drop all pending queued + steering work (un-started prompts never run; the // cancelled turn's steering is not re-enqueued). Cleared directly even when @@ -251,6 +266,7 @@ export class ReactLoopAgent implements Agent { disposed: this.disposed, isDisposed: () => this._status === 'disposed', isCancelled: () => this.cancelRequested, + cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, // Settle whenIdle() waiters WITHOUT a status transition — the pre-step // cancel-skip path drops the about-to-run turn and re-parks without ever diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index 1c78f49beb..3a6ac76a74 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -116,6 +116,14 @@ export interface LoopHandle { * marker governs exactly one cancellation and never leaks to a later prompt. */ isCancelled(): boolean + /** + * The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read + * by the marker branches (pre-step / continuation) so a turn dropped where no + * `AbortController` carries the reason still records the caller's + * `cancel(reason)` value — matching the mid-step abort path. Only meaningful + * when {@link isCancelled} is true. + */ + cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void /** @@ -396,7 +404,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // already-appended step/start. if (handle.isCancelled()) { handle.setAbort(undefined) - reason = { kind: 'aborted', reason: 'cancelled' } + reason = { kind: 'aborted', reason: handle.cancelReason() } closeStep() break } @@ -466,7 +474,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // ends the turn here. cancel() also cleared the steering FIFO, so the // override above did not re-arm continuation. if (handle.isCancelled()) { - reason = { kind: 'aborted', reason: 'cancelled' } + reason = { kind: 'aborted', reason: handle.cancelReason() } break } diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/agent-loop/tests/cancel.spec.ts index b8c7a4f78e..ab05c4e25b 100644 --- a/packages/agent-loop/tests/cancel.spec.ts +++ b/packages/agent-loop/tests/cancel.spec.ts @@ -186,9 +186,11 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) dispose() - // No step streamed (the model never ran), and the turn ended aborted. + // No step streamed (the model never ran), and the turn ended aborted with + // the CALLER's reason — the marker carries `cancel(reason)` through even + // though no AbortController observed it in this window. expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }]) }) it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { @@ -219,9 +221,10 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // Only ONE step ran (the second was cancelled in the continuation window), - // and the turn ended aborted. + // and the turn ended aborted with the CALLER's reason (carried by the + // marker, since the finished step's AbortController was already cleared). expect(steps).toBe(1) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }]) }) it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { From de0c4605bd59918667008b155bb6c443e5db46e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:55:56 +0800 Subject: [PATCH 16/87] =?UTF-8?q?docs(acp):=20correct=20teardown=20wording?= =?UTF-8?q?=20=E2=80=94=20dispose=20uses=20the=20disposed=20path,=20not=20?= =?UTF-8?q?cancel()=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer noted the quiesce() comment + ACP README said `AgentHandle.dispose()` stops the loop "with the queue-aware cancel", but the handle delegates to the start-disposer's `stop(); await agent.done`, where `stop()` sets `disposed` and aborts the current controller — it does NOT call `agent.cancel()`. The pre-step teardown window is still closed (the disposed promise wakes the parked loop and `isDisposed()` breaks before a turn starts), but the mechanism is the DISPOSED path and a mid-flight turn ends with reason `disposed`, not `aborted`. Corrected the comment and the README to describe the actual path. (This commit follows the merge of PR C's `cancel(reason)` fix up into this branch.) --- packages/acp/README.md | 2 +- packages/acp/src/index.ts | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/acp/README.md b/packages/acp/README.md index 5cdfa3a202..78c35eff86 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -61,7 +61,7 @@ A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical s ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop with the queue-aware `cancel()`, `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index d6eda1cc04..d107c2c0cb 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -630,19 +630,21 @@ export function apply(ctx: Context, config: AcpConfig): void { * Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach * quiescence"): for each session settle any pending prompt `cancelled`, then * run that session's {@link AgentHandle} `dispose()` — which stops the loop - * with the queue-aware cancel, AWAITS the loop's exit (the final - * `turn/end` + `session/flush` are captured while `onAppend` is still + * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the + * final `turn/end` + `session/flush` are captured while `onAppend` is still * attached), unregisters the agent, and removes its session from the store. * The per-session disposes run in parallel. Idempotent — clears the `sessions` * map first and memoizes, so a second call (close racing dispose) is a no-op. * Shared by Cordis disposal AND client disconnect (`conn.closed`). * - * Per-agent disposal closes the former pre-step best-effort window: the - * queue-aware `cancel()` (RFC 011) drops a turn about to start, so a queued- - * but-not-yet-running prompt never runs after teardown. A bare client - * disconnect (resolves `conn.closed` WITHOUT disposing the fiber) thus leaves - * NO registered agent and NO session-store entry — not an idled-but-still- - * registered one. When the fiber IS disposed (whole-context or an ACP-only HMR + * Per-agent disposal closes the former pre-step best-effort window — but via + * the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`, + * which wakes the parked loop, and `isDisposed()` breaks the loop before a + * queued-but-not-yet-running turn can start (a turn cut off mid-flight ends + * with reason `disposed`, not `aborted`). A bare client disconnect (resolves + * `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent + * and NO session-store entry — not an idled-but-still-registered one. When the + * fiber IS disposed (whole-context or an ACP-only HMR * `acpFiber.dispose()`), this same memoized teardown runs first; the factory's * register+start+session effects are ALSO bound to the bridge fiber (the * factory is reached through this bridge's traceable service proxy, so @@ -667,10 +669,10 @@ export function apply(ctx: Context, config: AcpConfig): void { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') // Per-agent dispose (the AgentHandle disposer): unregister this agent, - // stop its loop with the queue-aware cancel, await quiescence (the loop - // exit + final flush), and remove its session — so a bare client - // disconnect leaves NO registered agent and NO session-store entry, not - // just an idled-but-still-registered one. + // stop its loop (sets disposed + aborts the in-flight step), await + // quiescence (the loop exit + final flush), and remove its session — so + // a bare client disconnect leaves NO registered agent and NO + // session-store entry, not just an idled-but-still-registered one. await rec.dispose() })) })() From 1bb201365d30bb5e4216fc71fbacf074dceb07b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:42:53 +0800 Subject: [PATCH 17/87] docs(agents): add doc-current-state convention + sharpen the summary worked example (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the AGENTS.md additions: - Add a convention to § Type Safety and Documentation: document the CURRENT state (what + why), never the PROCESS/HISTORY of how the code got there. No "previously/now/used-to/replaces/the old X" in comments or JSDoc — that rots on the next change and belongs in the commit message / PR / RFC. A standing contrast against a live alternative is fine; a contrast against the codebase's past is not. - The "tests document behavior" worked example overstated the audit as "nothing in production read or wrote" the summary. The backends DID write it (JSONL sidecar, SQLite updated_at); what made it dead was no CONSUMER and no update() caller. Corrected so a future reader does not infer the write path never existed. --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bb32121e9..9c584b2072 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ A passing test pins the behavior the code **currently** has — not necessarily Before you preserve a behavior solely to keep a test green, ask: is this behavior load-bearing (a real consumer depends on it, a contract promises it, a user observes it), or is it an artifact? If it's an artifact, **change the behavior AND its test together, in the same change, and say why in the PR** — do not contort new code to keep an obsolete assertion passing, and do not treat "but the test expects X" as a reason X must stay. Conversely, do not delete a test just because it is inconvenient: the discipline cuts both ways — you must show the *behavior* is dead, not merely that the test is in your way. -The worked example is [Drop the mutable session summary](docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet nothing in production read or wrote any of it. The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) +The worked example is [Drop the mutable session summary](docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet **nothing in production CONSUMED any of it, and `update()` had no production caller**. (The backends did *write* summary state — JSONL touched the sidecar after a durable append, SQLite bumped `updated_at` in the append transaction — but those writes fed only reads that nothing performed.) The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) ## Architecture @@ -176,7 +176,7 @@ In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `p Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, and checks that every relative Markdown cross-link resolves — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Write an RFC when — and only when — a PR makes a decision that is durable, contested, and surprising.** RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. +**Document the CURRENT state — the "what" and "why" — never the PROCESS or HISTORY of how it got there.** A comment, JSDoc, or doc paragraph describes what the code *is* and why it is that way, as if it had always been so. Do NOT narrate the change that produced it: no "previously X, now Y", "changed from", "used to", "this replaces", "the old map", "renamed", "moved here", "as of this PR", or "(was …)". Such phrasing rots the instant the next change lands, and a reader of the current code does not need the diff narrated in prose — that belongs in the commit message, the PR description, or an RFC (the durable home for "why we moved away from X"). Write "the owner token lives on the task in the executor" — not "ownership *now* lives on the executor instead of a plugin-local map". When a contrast genuinely aids understanding (a non-obvious choice between live alternatives), frame it against the alternative as a standing fact ("stored on the executor, NOT the tool plugin, so it survives an HMR reload"), not against the codebase's past. The same rule governs review-fix commits: the *commit message* records what the review caught; the *code comment* it touches states only the resulting truth. RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. **Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. `pnpm run verify-md-wrap` (part of `doc-sync`) enforces this across `README.md`, `docs/**/*.md`, `packages/*/README.md`, and `AGENTS.md` / `packages/AGENTS.md`; `pnpm run verify-md-links` (also part of `doc-sync`) checks that every relative cross-link in those files resolves. From 301a3d12338ecaba475342655140f49e3f61bca1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:50:55 +0800 Subject: [PATCH 18/87] fix(session-persistence): scope the ownerless-state claim to the cwd (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer found a cross-cwd hole: the ownerless-state claim path validated only the seed prefix (via loadStored, any scope) and never compared the tracked header's cwd to the live session's. So an ownerless `create(meta(id, "/a"))` with cursor 0 (seed matches trivially) was claimed by a live session with the same id at cwd "/b", and the "/b" events then appended under the "/a" header — bypassing the cwd-scoped loadLive() guard that the HMR-adopt path (case 2) uses. Add a cwd equality check before the seed check in the ownerless-claim branch: a same-id ownerless artifact at a different cwd is a collision, not a claim. This is a coordinator-level invariant (the live session's cwd must match the tracked meta's cwd) and applies to both backends. Tests (shared coordinator contract, run per backend): a live session at a different cwd cannot claim cursor-0 ownerless state, cannot claim a loaded-prefix even when the seed matches, and a no-cwd state cannot be claimed by a cwd'd session. All fail without the guard. Also documents WHY the `materialized` flag is needed (lazy create leaves no artifact; it distinguishes registered-but-unwritten from durably-present for has()/reclaim) and reframes the module doc to current-state, not the refactor history (per the new AGENTS.md doc convention). --- .../session-persistence/src/coordinator.ts | 54 +++++++++++++------ .../tests/coordinator-contract.ts | 53 ++++++++++++++++++ 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/src/coordinator.ts index f35685c677..5371873097 100644 --- a/packages/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/src/coordinator.ts @@ -2,21 +2,20 @@ * The backend-agnostic write-path orchestration shared by every first-party * {@link SessionPersistence} backend. * - * The two durable backends (`dsh-session-persistence-jsonl` over file bytes, - * `dsh-session-persistence-sqlite` over `node:sqlite` rows) were byte-identical - * — or same-algorithm — for ALL of their orchestration: the in-memory - * bookkeeping (the per-id state, the write-behind buffers, the per-id - * serialization chains, the per-session init promises), the `session/event` → - * buffer → `session/flush` drain, lazy materialization, crash-tail repair on - * load, the four `session/created` adoption cases (new / HMR-adopt / collision / - * ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives - * differed (write bytes vs. INSERT rows). {@link PersistenceCoordinator} owns - * the orchestration once; a backend supplies the storage primitives as a small + * Every durable backend needs the same orchestration: the in-memory bookkeeping + * (the per-id state, the write-behind buffers, the per-id serialization chains, + * the per-session init promises), the `session/event` → buffer → `session/flush` + * drain, lazy materialization, crash-tail repair on load, the four + * `session/created` adoption cases (new / HMR-adopt / collision / + * ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives are + * backend-specific (file bytes for `dsh-session-persistence-jsonl`, `node:sqlite` + * rows for `dsh-session-persistence-sqlite`). {@link PersistenceCoordinator} owns + * the orchestration; a backend supplies the storage primitives as a small * {@link PersistenceBackend} hook object. * - * The abstract {@link SessionPersistence} service's public API is unchanged: a - * backend still IS a `SessionPersistence` (its six public methods delegate to a - * coordinator it composes), so a third-party backend MAY implement the service + * The abstract {@link SessionPersistence} service's public API is independent of + * this: a backend IS a `SessionPersistence` (its six public methods delegate to + * a coordinator it composes), so a third-party backend MAY implement the service * directly without using the coordinator at all. * * See the write-coordinator RFC (docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md) @@ -115,7 +114,19 @@ interface SessionState { meta: SessionHeader /** The next seq the backend expects to append (the stored log length). */ cursor: number - /** Whether the session has been physically materialized. */ + /** + * Whether the backend has physically written this session (a JSONL file / + * SQLite row exists). `create()` registers state LAZILY — cursor 0, + * materialized false, nothing on disk — so an empty session leaves no + * artifact and the FIRST `appendBatch` writes the header + its events in ONE + * transaction (the "a row exists ⇔ it has events" invariant `has`/`list` + * rely on; a separate up-front materialize could crash leaving a row with + * zero events). The flag is the only signal that distinguishes a session + * registered-but-never-written from one durably present, which two callers + * need: `has()` (lazy-but-unwritten is not yet durable) and the reclaim path + * (an abandoned id with no artifact AND no buffered events is free to reuse; + * a materialized one is a real collision). + */ materialized: boolean /** * The live Session this state was bound to via `onCreated`, if any. State @@ -450,9 +461,18 @@ export class PersistenceCoordinator { if (tracked.owner === session) return if (tracked.owner === undefined) { // Ownerless state from the public create()/load() API. The FIRST live - // session claims it — but ONLY if its seed reproduces the persisted - // prefix (else a fresh, unrelated session reusing the id would have its - // seq 0..cursor-1 events filtered as already-written and grafted on). + // session claims it — but ONLY if BOTH the cwd scope and the seed match. + // The cwd guard mirrors case-2's cwd-scoped loadLive(): a same-id + // ownerless artifact at a DIFFERENT cwd is a collision, not a claim + // (claiming it would append the live cwd's events under the stored + // header's cwd, the exact cross-cwd corruption the loadLive scope + // prevents). The seed guard then ensures the live events reproduce the + // persisted prefix (else a fresh, unrelated session reusing the id would + // have its seq 0..cursor-1 events filtered as already-written and + // grafted on). + if (tracked.meta.cwd !== session.header.cwd) { + throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + } if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) { throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`) } diff --git a/packages/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/tests/coordinator-contract.ts index 24a8f4c0cf..7769e6f5ec 100644 --- a/packages/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/tests/coordinator-contract.ts @@ -68,6 +68,7 @@ export interface CoordinatorFixture { /** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */ const WORK = '/w' +const OTHER = '/other' /** The per-session init map a backend exposes for white-box init awaits. */ function inits(persistence: SessionPersistence): Map> { @@ -535,6 +536,58 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // create() registers ownerless state at cwd /a (cursor 0 — claims would + // otherwise match trivially on the seed). + await ctx.sessionPersistence.create(meta('wrong-cwd-claim', OTHER)) + // A live session reusing the id but at cwd WORK must NOT claim it — the + // cwd scope is the fence (without it, WORK events would append under the + // OTHER header). Rejected as a collision. + const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Materialize + load at cwd OTHER (ownerless, cursor = 6). + await ctx.sessionPersistence.create(meta('wrong-cwd-load', OTHER)) + await ctx.sessionPersistence.append(SessionId('wrong-cwd-load'), oneTurnLog()) + const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load')) + // A live session whose SEED matches the loaded prefix but whose cwd is + // WORK must still be rejected — the cwd guard runs before the seed check. + const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } }) + await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + // Ownerless state created WITHOUT a cwd (the no-cwd bucket). + await ctx.sessionPersistence.create(meta('no-cwd-state')) + // A live session reusing the id but WITH cwd WORK is a cwd mismatch + // (undefined vs WORK) and must be rejected. + const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } }) + await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + // --- append adopts a storage-only session (fresh instance, no prior create/load) --- it('append adopts a storage-only session (fresh instance) and continues the seq', async () => { From f58b031465c7def8a9b0809209cc62db4f0168ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:57:32 +0800 Subject: [PATCH 19/87] fix(agent): close the window-2 early-whenIdle race + sync cancellation RFC docs (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer found that window 2 (a cancel from a synchronous agent/status('running') listener) had the same early-whenIdle() race that window 1 already guards: it unconditionally `setStatus('idle')` + continue, which settles `whenIdle()` waiters — so if the running listener cancels AND queues replacement work, the waiter resolves while the replacement is still queued-and-unrun (the next iteration runs it later, but the caller already observed quiescence). Mirror window 1: after clearing the marker, only `setStatus('idle')` when nothing new is queued; otherwise fall through to run the queued replacement (status is already `running`), so `whenIdle()` resolves on that turn's running→idle. Regression test reproduces the reviewer's interleaving (running listener cancels A, sends B; whenIdle() resolves only after B ran). Also syncs the cancellation contract in the two ACP RFCs that describe the live behavior: `session/cancel` is the queue-aware `agent.cancel()` (drops an about-to-start turn), not the old best-effort `agent.abort()` pre-step limitation. --- .../2026-06-14-acp-agent-client-protocol.md | 4 +-- .../proposed/2026-06-14-acp-multi-session.md | 2 +- packages/agent-loop/src/loop.ts | 20 +++++++++---- packages/agent-loop/tests/cancel.spec.ts | 30 +++++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md index 3e68024fa9..3dc779ffa4 100644 --- a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. One further best-effort limitation is tracked as `TODO(rfc010-cancel-prestep)`: `session/cancel` aborts a running step and settles the RPC as `cancelled`, but a turn still queued (not yet started) when the cancel arrives may execute before the abort takes effect, pending a loop-level pre-step cancel. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. +> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. ## Problem @@ -33,7 +33,7 @@ The mapping between ACP and existing harness seams — each row names the seam a | `session/update: tool_call` (pending→in_progress) | `session/event` `tool/call` | demux via a Session→sessionId map; `kind` inferred from the tool name | | `session/update: tool_call_update` (completed/failed) | `session/event` `tool/result` | a throwing `tools/execute` yields NO `tool/result` → fail the pending tool UI from `agent/error`/turn-end | | `session/request_permission {sessionId, toolCall, options}` | prepended `tools/execute` listener | no-op unless `exec.agent` is ACP-owned; await the outcome; `selected/allow_*` → `next()`; `reject_*`/`cancelled` → veto `ToolExecutionResult{isError}` | -| `session/cancel` (notification) | `agent.abort(reason)` | settle the in-flight prompt as `cancelled`; resolve any pending permission as `cancelled` exactly once | +| `session/cancel` (notification) | `agent.cancel(reason)` | the queue-aware cancel (abort running step, clear queued + steering, drop an about-to-start turn); settle the in-flight prompt as `cancelled`; resolve any pending permission as `cancelled` exactly once | The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 8cc2237eff..25fe24c972 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -18,7 +18,7 @@ The harness core already supports many agents (`AgentRegistry.list()` and `Agent - Lift the single-session guard in `session/new`; allow N live sessions, each mapped to its own `ReactLoopAgent`. - The bridge's `sessionId→agent` and `Session→sessionId` maps (introduced single-entry by [the ACP support RFC](2026-06-14-acp-agent-client-protocol.md)) become true multi-entry, plus a third `agent→sessionId` reverse map: the `tools/execute` permission gate receives only `exec.agent` (no sessionId), so it needs an O(1) reverse lookup to find the owning session. Every `agent/*` event and every `session/event` is demuxed strictly by id, so two sessions streaming at once never interleave their `session/update` notifications. - Per-session prompt queues: [the ACP support RFC](2026-06-14-acp-agent-client-protocol.md)'s single-entry in-flight-prompt state becomes multi-entry — one in-flight prompt *per session*, tracked per `sessionId`. -- Per-session cancel routing: `session/cancel` aborts only its own session's agent and settles only that session's in-flight prompt. `agent.abort()` drives a per-agent `AbortController`, so the per-session `exec.signal` is the natural isolation fence. +- Per-session cancel routing: `session/cancel` cancels only its own session's agent (via the queue-aware `agent.cancel()`) and settles only that session's in-flight prompt. The cancel is scoped to that one agent — a per-agent `AbortController` for the running step plus the agent's own queued/steering FIFOs — so it never touches another session's stream or pending prompt. - Per-session permission ownership: a `session/request_permission` and its outcome are bound to the originating session via the reverse map, so a permission prompt or a cancel in one session can never resolve another session's pending permission. ## Plan diff --git a/packages/agent-loop/src/loop.ts b/packages/agent-loop/src/loop.ts index 3a6ac76a74..b8f43a0a9b 100644 --- a/packages/agent-loop/src/loop.ts +++ b/packages/agent-loop/src/loop.ts @@ -201,14 +201,22 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` // SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the - // check above and `runTurn`. `cancel()` already cleared the queued FIFO, so - // drop the turn before it starts (runTurn would otherwise throw on an empty - // queue) and transition back to idle — `running` was already emitted, so a - // real `idle` transition (which also settles waiters) balances the status. + // check above and `runTurn`. Mirror window 1: clear the marker, then + // - if NOTHING new is queued, drop the about-to-run turn and transition + // back to `idle` (`running` was already emitted, so a real idle + // transition balances the status AND settles `whenIdle()` waiters); + // - if a NEW prompt was queued AFTER the cancel (a `running` listener that + // cancels then sends), the marker was for the cancelled work only — fall + // through and run the new prompt's turn (status is already `running`), so + // a `whenIdle()` waiter resolves on THAT turn's running→idle, not before + // it runs. Settling here would resolve quiescence while the replacement + // is still queued and unrun (the same early-resolve race window 1 fixes). if (handle.isCancelled()) { handle.clearCancel() - handle.setStatus('idle') - continue + if (!agent.inbox.hasQueued) { + handle.setStatus('idle') + continue + } } // Re-derive the turn number from the log each iteration (do NOT keep a local diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/agent-loop/tests/cancel.spec.ts index ab05c4e25b..9392b4b3d0 100644 --- a/packages/agent-loop/tests/cancel.spec.ts +++ b/packages/agent-loop/tests/cancel.spec.ts @@ -252,6 +252,36 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) + it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => { + // The window-1 early-resolve race has a window-2 twin: a synchronous + // agent/status('running') listener cancels the about-to-run turn AND queues a + // replacement. window 2 must NOT settle waiters (via setStatus('idle')) while + // the replacement is still queued-and-unrun — it must fall through and run it, + // so whenIdle() resolves on the replacement turn's running→idle, not before. + const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + + let replaced = false + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'running' || replaced) return + replaced = true + agent.cancel('drop A') + send(agent, 'B') + }) + + send(agent, 'A') + const idle = agent.whenIdle() + await idle + dispose() + + // whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end + // are in the log, and A was dropped. + expect(userTexts(agent)).toContain('B') + expect(userTexts(agent)).not.toContain('A') + expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + }) + it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => { // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() // clears A; prompt B is queued BEFORE the loop resumes from the idle wait. From 083a6fc9902c16f291374ebfb98b7007fd4402ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:06:28 +0800 Subject: [PATCH 20/87] fix(agent): re-check id in enter() + memoize AgentHandle.dispose() (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking lifecycle findings from the deep review: - `SessionStore.enter()` is a public cross-package primitive that a caller can separate from `prepare()` by arbitrary work, so it must re-check the id: a stale prepared session could otherwise overwrite a live store entry of the same id, and the stale session's detach disposer would later delete the REAL session. Re-add the duplicate-id throw (removed earlier on a coverage rationale that only held for the back-to-back internal caller). Tests cover the stale-overwrite rejection and the prepare/enter/announce lifecycle (which also covers the throw branch). - `AgentHandle.dispose()` exposed the raw single-shot cordis effect disposer, so a concurrent/second dispose() returned immediately (effect epoch already cleared) instead of awaiting the in-flight teardown — violating the dispose(): Promise contract that every caller observes the same quiescence boundary. Memoize the disposal promise in startOwned. Regression test gates the loop's final flush, fires two dispose() calls, and asserts the second stays pending until the first's teardown completes (fails without the memo). --- packages/acp/tests/dispose.spec.ts | 43 ++++++++++++++++++++++++++ packages/agent-loop/src/index.ts | 14 +++++++-- packages/session/src/index.ts | 16 ++++++---- packages/session/tests/session.spec.ts | 34 ++++++++++++++++++++ 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index d3af9ccd6c..3196994a0e 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -273,4 +273,47 @@ describe('acp bridge — disposal & HMR safety', () => { expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran await harness.dispose() }) + + it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { + // The handle's dispose() must memoize: the underlying cordis effect disposer + // is single-shot, so a second dispose() while the first is mid-teardown would + // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the + // first call's await agent.done + final flush finished. Every caller must + // observe the same quiescence boundary. + const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) + const handle = harness.ctx.agents.create({ + agentId: 'conc-a', sessionId: 'conc-a', agentOptions: { model: 'mock' }, + }) + // Drive a turn that hangs in the model stream, so the loop is mid-turn when + // disposed — its exit runs a final session/flush we can gate to hold the + // teardown observably in-flight. + handle.agent.send([{ type: 'text', text: 'go' }]) + await new Promise(r => setTimeout(r, 30)) + expect(handle.agent.status).toBe('running') + let releaseFlush!: () => void + const flushGate = new Promise((resolve) => { releaseFlush = resolve }) + harness.ctx.on('session/flush', () => flushGate) + + // First dispose enters teardown (aborts the hanging step) and blocks in the + // gated final flush. + const first = handle.dispose() + let firstSettled = false + void first.then(() => { firstSettled = true }) + await new Promise(r => setTimeout(r, 20)) + expect(firstSettled).toBe(false) + + // Second dispose MUST await the same in-flight teardown, not resolve early. + const second = handle.dispose() + let secondSettled = false + void second.then(() => { secondSettled = true }) + await new Promise(r => setTimeout(r, 20)) + expect(secondSettled).toBe(false) // memoized: still pending with the first + + // Release the flush; both resolve together and the session is gone. + releaseFlush() + await Promise.all([first, second]) + expect(harness.ctx.agents.get('conc-a')).toBeUndefined() + expect(harness.ctx.sessions.get('conc-a')).toBeUndefined() + await harness.dispose() + }) }) diff --git a/packages/agent-loop/src/index.ts b/packages/agent-loop/src/index.ts index 3963b24594..5c25eb197d 100644 --- a/packages/agent-loop/src/index.ts +++ b/packages/agent-loop/src/index.ts @@ -267,15 +267,25 @@ export class AgentLoop extends Service implements AgentFactory { /** * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The - * handle's `dispose()` just runs the composite effect's disposer (see + * handle's `dispose()` runs the composite effect's disposer (see * {@link start}) — which stops the loop, awaits its exit (final flush * captured), unregisters the agent, and detaches the session, in that order. * The same composite effect is what a fiber unload disposes, so both teardown * triggers honor the ordering identically. + * + * `dispose()` is MEMOIZED: the underlying cordis effect disposer is + * single-shot (a second call returns immediately because the effect's epoch is + * already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated + * `dispose()` calls would otherwise resolve before the first call's + * `await agent.done` + final flush completed. Memoizing the promise makes every + * caller observe the SAME quiescence boundary, honoring the + * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` + * helper). */ private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle { const { agent, disposeAgent } = this.start(id, options, session) - return { agent, dispose: disposeAgent } + let disposing: Promise | undefined + return { agent, dispose: () => (disposing ??= disposeAgent()) } } } diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 8d1471d5f3..57210c3431 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -285,14 +285,18 @@ export class SessionStore extends Service { * {@link announce}, so a throwing `session/created` listener rolls the attach * back instead of leaking it. * - * The id was already validated by {@link prepare}, which runs in the SAME - * synchronous sequence as `enter` (a config/factory caller does - * `prepare()` → `ctx.effect(generator)`, and a synchronous generator effect - * iterates inline — no await between them), so no concurrent create can claim - * the id in the gap. `enter` therefore does not re-check; it is not a public - * reservation primitive. + * Re-checks the id for a duplicate: `prepare` and `enter` are public + * cross-package primitives and a caller may interleave arbitrary work (or + * another create) between them, so a stale prepared session must NOT overwrite + * a live store entry of the same id — its detach disposer would later delete + * the REAL session. The {@link create} convenience and the agent factory call + * the two back-to-back so they never trip this, but the public seam cannot + * assume that. + * + * @throws if a session with this id is already in the store. */ enter(session: Session): () => void { + if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`) session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(session.id, session) return () => { diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index ac40a8ea3f..593eed36c0 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -221,6 +221,40 @@ describe('SessionStore', () => { expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) + it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => { + // prepare()/enter() are public cross-package primitives that a caller may + // separate with arbitrary work. A stale prepared session must NOT overwrite + // a live store entry of the same id — its detach disposer would later delete + // the REAL session, breaking the store-uniqueness invariant. + const ctx = new Context() + await ctx.plugin(SessionStore) + const stale = ctx.sessions.prepare('racy') + const live = ctx.sessions.create('racy') + expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/) + // The live session is intact and still the store entry. + expect(ctx.sessions.get('racy')).toBe(live) + }) + + it('prepare() + enter() + announce() register a session and emit session/created', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const created: Session[] = [] + ctx.on('session/created', session => void created.push(session)) + + const session = ctx.sessions.prepare('lifecycle') + // prepare alone does NOT enter the store. + expect(ctx.sessions.get('lifecycle')).toBeUndefined() + const detach = ctx.sessions.enter(session) + expect(ctx.sessions.get('lifecycle')).toBe(session) + // enter does NOT announce. + expect(created).toEqual([]) + ctx.sessions.announce(session) + expect(created).toEqual([session]) + // The detach disposer removes the entry + stops notification. + detach() + expect(ctx.sessions.get('lifecycle')).toBeUndefined() + }) + it('synthesizes a minimal v1 header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) From 90a19f072d73c0c2a283f28968bf89d20da48872 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:38:48 +0800 Subject: [PATCH 21/87] docs(acp,rfc): fix stale ownership wording + propose unifying agent/session id (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the bash owner-token PR: - packages/acp/README.md still described task isolation in object-identity terms ("records each background task's owning agent", "a different agent"). Rewrite to the session-token model: ownership is by `session.header.id`, stored on the executor's task, so a different Agent object on the same session may access it and ownership survives a tool-bash HMR reload. - The reviewer flagged that the notice routes by `session.header.id` while the registry only enforces unique `agent.id`, so a programmatic caller could register two agents sharing a session token and mis-route a notice (not reachable via ACP). Rather than bolt a session-id invariant onto the generic registry, add a proposed RFC (2026-06-20-unify-agent-and-session-id) to remove the precondition by construction — an agent IS its session, one id — with a full risks discussion (forecloses multi-session-actor / fork futures, makes the config resume-or-create policy load-bearing, migration churn). The actual unification ships as its own Codex-converged PR. Cross-linked from the agent-lifecycle RFC's seam-precondition note. - Reframe the tool-bash module-doc ownership paragraph to current-state (per the new AGENTS.md doc convention): contrast storing the token on the executor vs in the plugin as a standing rationale, not as "closing the old gap". --- docs/rfc/README.md | 1 + ...-18-agent-lifecycle-and-ownership-seams.md | 2 + .../2026-06-20-unify-agent-and-session-id.md | 57 +++++++++++++++++++ packages/acp/README.md | 2 +- packages/tool-bash/src/index.ts | 9 +-- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 docs/rfc/proposed/2026-06-20-unify-agent-and-session-id.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 196958a709..0fe36dca75 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -31,6 +31,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Multiplex concurrent ACP sessions over one connection](proposed/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | +| [Unify the agent id and the session id](proposed/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | ## Implemented diff --git a/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md index 9f4e930b44..839e5d6c9f 100644 --- a/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -35,6 +35,8 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map`). +- **Resume**: a caller-supplied `agentId` (e.g. `"main"`) on a persisted `resumeSessionId`. + +Everywhere a live consumer actually looks an agent up — the **ACP bridge, the only production path** — the two are already unified: `agentId === sessionId === `. + +The separation is **latent generality no consumer exercises**: nothing reads a *stable* `agentId` back across runs (each process starts fresh, and persistence keys off the session id, never the agent id). The config path's "stable agentId, fresh sessionId" buys nothing concrete — it is cosmetic. And the `agentId !== sessionId` case is precisely what opens the bash owner-token alias hole: the bash completion-notice routes by `session.header.id`, but the registry enforces uniqueness only on `agentId`, so a programmatic caller registering two agents with different agent ids but the SAME session id can mis-route a notice (see [agent lifecycle and ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) § Seam precondition). The current code documents this as a precondition rather than guaranteeing it. + +## Proposal + +Make an agent BE its session: one id. An agent's registry handle IS its `session.header.id`. + +- `CreateAgentOptions` drops the separate `sessionId` — the single `id` is both the registry handle and the live/persisted session id. (ACP already passes the same UUID for both, so its call site simplifies to one field.) +- `ResumeAgentOptions` drops the separate `agentId` — resuming `sessionId` X registers the agent under id X. (ACP already does this.) +- The config path (`AgentLoop.create`) uses its configured `id` directly as the session id, applying whatever resume-or-create policy it adopts (today it appends a per-run uuid to avoid colliding with an on-disk log; that policy moves onto the single id, e.g. the config id IS the session and a durable backend resumes it — to be settled in the implementing PR). +- The registry's existing unique-`agentId` check becomes, by construction, a unique-session-id guarantee — the bash alias hole is closed with NO new defensive invariant: two agents cannot share a session id because the session id is the agent id. + +## Why not just enforce session-id uniqueness in `AgentRegistry.register()`? + +That was the review's first suggestion. It would couple the generic registry to a session-uniqueness assumption (the registry tracks *agents*, not sessions) and entrench the very separation this RFC removes. Unifying the ids closes the hole more cleanly — there is nothing left to enforce. + +## Acceptance criteria + +- `ctx.agents.create`/`resume` take a single id; the ACP bridge passes one id. +- The config-driven agent path has a deliberate, documented session-id policy (no silent per-run id divergence that no consumer reads). +- The bash owner-token alias hole is gone by construction (no two live agents can share a session id). +- All existing behavior the tests pin (ACP create/resume/load, config startup, durability) still holds — or the tests change WITH the behavior where the divergence was an artifact (per AGENTS.md "tests document behavior, not golden truth"). + +## Risks + +This touches public factory interfaces (`CreateAgentOptions`, `ResumeAgentOptions`, `AgentFactory`) and the config-agent id scheme, so it is a deliberate cross-package change, not a local patch — it ships as its own PR (converged with Codex), stacked on the bash owner-token work that surfaced the precondition. + +The genuine risks of collapsing the two ids into one (the case AGAINST this proposal — to be weighed honestly before implementing): + +- **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. + +- **Sub-agents / fork / spawn (an explicitly deferred seam) may WANT a stable actor id across forked sessions.** `AgentLoop.create`'s `TODO(sub-agents)` envisions a child agent seeded from a parent's event log. If the design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. + +- **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. + +- **Persisted/on-disk identity becomes the agent identity.** Unifying means the registry handle is now a persisted, externally-meaningful string (a session id a client chose), not an internal label. A caller that previously used a short human label (`"main"`) as the agent id now must use the session id. This is fine for ACP (already a UUID) but is a semantic narrowing for any programmatic embedder that relied on naming its agents independently of session storage. + +- **Migration churn touches every create/resume call site and its tests.** `CreateAgentOptions`/`ResumeAgentOptions` shape changes ripple to ACP, the config path, the agent-loop factory, and ~dozens of test fixtures that currently pass distinct `agentId`/`sessionId` (some deliberately distinct to exercise the divergence — those tests change WITH the behavior, per AGENTS.md "tests document behavior, not golden truth"). The risk is mechanical but broad; a missed call site is a type error, but a missed *test* could silently lose coverage of a path. + +The one real design question the implementing PR must settle first is the config-driven resume-or-create policy once the id is unified (today's per-run-uuid behavior is a demo simplification already flagged `TODO(demo)`). If, on closer look, the fork/spawn or multi-session-actor futures turn out to be wanted, this RFC should be REJECTED in favor of the lighter "enforce session-id uniqueness in the registry" guard — the alias hole is not reachable via ACP, so keeping the ids separate and merely documenting (or mechanically enforcing) the precondition remains a valid alternative. diff --git a/packages/acp/README.md b/packages/acp/README.md index 78c35eff86..23f32b9bd4 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -34,7 +34,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.) -Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so the tool layer records each background task's owning agent and `bash_output`/`bash_kill` reject a task owned by a different agent — one session's agent can't read or kill another's task. +Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. ## Per-session cwd diff --git a/packages/tool-bash/src/index.ts b/packages/tool-bash/src/index.ts index 7302aed6f1..0ea01ca50d 100644 --- a/packages/tool-bash/src/index.ts +++ b/packages/tool-bash/src/index.ts @@ -22,10 +22,11 @@ * multi-session ACP (RFC 011) this token check is the fence that stops one * session's agent from reading or killing another session's background task. * - * Because ownership lives on the task in the EXECUTOR (disposed with the - * `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old - * plugin-local-map gap where a reload orphaned pre-reload tasks. (The - * `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a + * Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash` + * fiber), rather than in this plugin, is what makes ownership survive a + * `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan + * a task spawned before it. (The `onTaskDone` listener is still effect-scoped + * to this plugin's `apply`, so a * completion landing during the reload gap still drops its one notice — the * pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) * From 329e5c3e2e90aaf4538c71bc3fd176ded70eaa24 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:51:09 +0800 Subject: [PATCH 22/87] docs(session-persistence): state coordinator-contract role as current fact (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword the suite's module doc to describe what it IS — each scenario lives here once and runs per backend through the fixture — rather than narrating that the scenarios were previously duplicated in the per-backend specs. Per the repo doc-current-state convention (no process/history in comments). --- packages/session-persistence/tests/coordinator-contract.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/tests/coordinator-contract.ts index 7769e6f5ec..9f42eebd10 100644 --- a/packages/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/tests/coordinator-contract.ts @@ -20,9 +20,8 @@ * write path — never the storage primitives directly — so it runs unchanged for * every backend (memory / jsonl / sqlite). * - * Each scenario here was previously DUPLICATED in `jsonl.spec.ts` and - * `sqlite.spec.ts`; it now lives once and runs once per backend through the - * fixture. The per-backend specs keep ONLY their storage-mechanics tests. + * Each scenario lives here once and runs once per backend through the fixture; + * the per-backend specs keep ONLY their storage-mechanics tests. * * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract */ From 16304872e134a4e1f712973c7d5d3c0624c1ff4e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:51:30 +0800 Subject: [PATCH 23/87] docs(agent-loop): drop PR-letter ref from cancel test header (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancel.spec.ts module doc named "PR C", narrating the change's origin — process/history a reader of the current test does not need. Per the repo doc-current-state convention, describe only what the suite tests. --- packages/agent-loop/tests/cancel.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/agent-loop/tests/cancel.spec.ts index 9392b4b3d0..a4e9e13a1c 100644 --- a/packages/agent-loop/tests/cancel.spec.ts +++ b/packages/agent-loop/tests/cancel.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the queue-aware `Agent.cancel()` primitive (PR C). `cancel()` is the + * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the * broad verb — it clears queued + steering work, aborts an in-flight step, and * drops a turn about to start — whereas `abort()` kills only the current step. * These tests exercise every window where a cancel can land (idle, pre-step, From 44762efbd7e387201c52fef78260225b77907f7e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:51:42 +0800 Subject: [PATCH 24/87] docs(acp): drop PR-letter ref from dispose test comment (review) The disconnect-mid-prompt test comment said "PR D's per-agent AgentHandle teardown", narrating the change's origin. Per the repo doc-current-state convention, state the mechanism (the session's AgentHandle teardown) without naming the PR that introduced it. --- packages/acp/tests/dispose.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/acp/tests/dispose.spec.ts b/packages/acp/tests/dispose.spec.ts index 3196994a0e..dd27cc7e34 100644 --- a/packages/acp/tests/dispose.spec.ts +++ b/packages/acp/tests/dispose.spec.ts @@ -85,7 +85,7 @@ describe('acp bridge — disposal & HMR safety', () => { it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { // The ACP transport closes (editor quits) while a turn runs. The bridge must - // settle the in-flight prompt cancelled and DISPOSE the agent (PR D's + // settle the in-flight prompt cancelled and DISPOSE the agent (the session's // per-agent AgentHandle teardown) rather than leaving an orphaned running — // or even idled-but-still-registered — agent whose updates are swallowed. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) From 8597cc2c58ad56d9e1ab6e49f170db88e926b8bb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:52:02 +0800 Subject: [PATCH 25/87] docs(tool-bash): state ownership tests as current fact (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ownership tests narrated the change's history — "the old design fenced by Agent object identity", "closing the old XXX(tool-bash-owner-hmr) gap". Reword to state the current contract (ownership fences by session.header.id; the token lives on the executor task, so a tool-bash reload preserves it) without referencing the prior design. Per the repo doc-current-state convention. --- packages/tool-bash/tests/tools.spec.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/tool-bash/tests/tools.spec.ts index e94cf108a9..c49410a9b2 100644 --- a/packages/tool-bash/tests/tools.spec.ts +++ b/packages/tool-bash/tests/tools.spec.ts @@ -490,10 +490,10 @@ describe('background task ownership (cross-session isolation)', () => { expect(text(killByA)).toBe(`killed background task ${id}`) }) - it('a DIFFERENT Agent object with the SAME session token may access the task (identity no longer matters)', async () => { - // The old design fenced by Agent object identity; the token design fences by - // session.header.id. Two distinct Agent objects sharing one session token - // (e.g. an agent re-created on the same session) are now the SAME owner. + it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => { + // Ownership fences by session.header.id, NOT Agent object identity. Two + // distinct Agent objects sharing one session token (e.g. an agent re-created + // on the same session) are the SAME owner. const ctx = await setup() const a1 = fakeAgent('sess-shared') const a2 = fakeAgent('sess-shared') // distinct object, same token @@ -546,10 +546,9 @@ describe('background task ownership (cross-session isolation)', () => { it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => { // The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT // in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor + - // task survive) preserves ownership — closing the old XXX(tool-bash-owner-hmr) - // gap where the fresh map orphaned pre-reload tasks. This is the regression - // guard: an accidental return to a plugin-local map would make B accessible - // after reload, and this test would catch it. + // task survive) preserves ownership. This is the regression guard: a + // plugin-local map would make B accessible after reload, and this test would + // catch it. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) From 38cc62b6445b7941a7c1b4d5aa65f8c59356b82b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:53:56 +0800 Subject: [PATCH 26/87] docs(AGENTS): forbid naming the change unit (PR/commit) in comments & test names (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc-current-state convention already forbade process-narration phrasing, but review caught a subtler slip it did not name explicitly: comments and test descriptions that reference the PR / stack position that introduced the code ("(PR D's teardown)", "Tests for … (PR C)", "identity no longer matters"). The reader of the current tree has no PR D or prior design to anchor against. Add an explicit clause: never name the unseeable change unit in a comment, JSDoc, or test name — state the mechanism instead. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9c584b2072..424780bf74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `p Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, and checks that every relative Markdown cross-link resolves — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Document the CURRENT state — the "what" and "why" — never the PROCESS or HISTORY of how it got there.** A comment, JSDoc, or doc paragraph describes what the code *is* and why it is that way, as if it had always been so. Do NOT narrate the change that produced it: no "previously X, now Y", "changed from", "used to", "this replaces", "the old map", "renamed", "moved here", "as of this PR", or "(was …)". Such phrasing rots the instant the next change lands, and a reader of the current code does not need the diff narrated in prose — that belongs in the commit message, the PR description, or an RFC (the durable home for "why we moved away from X"). Write "the owner token lives on the task in the executor" — not "ownership *now* lives on the executor instead of a plugin-local map". When a contrast genuinely aids understanding (a non-obvious choice between live alternatives), frame it against the alternative as a standing fact ("stored on the executor, NOT the tool plugin, so it survives an HMR reload"), not against the codebase's past. The same rule governs review-fix commits: the *commit message* records what the review caught; the *code comment* it touches states only the resulting truth. RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. +**Document the CURRENT state — the "what" and "why" — never the PROCESS or HISTORY of how it got there.** A comment, JSDoc, or doc paragraph describes what the code *is* and why it is that way, as if it had always been so. Do NOT narrate the change that produced it: no "previously X, now Y", "changed from", "used to", "this replaces", "the old map", "renamed", "moved here", "as of this PR", or "(was …)". **In particular, NEVER name the change unit a reader cannot see — the PR, commit, or stack position that introduced the code — in a comment, JSDoc, OR a test name/description.** A `// (PR D's per-agent teardown)` aside, a `* Tests for the cancel primitive (PR C).` module doc, or an `it('… identity no longer matters')` title that only makes sense relative to a prior design are all the same violation: the reader of the current tree has no "PR D" or "old design" to anchor against, and the reference rots the moment the stack merges. Name the *mechanism* (`the session's AgentHandle teardown`), not the PR. Such phrasing rots the instant the next change lands, and a reader of the current code does not need the diff narrated in prose — that belongs in the commit message, the PR description, or an RFC (the durable home for "why we moved away from X"). Write "the owner token lives on the task in the executor" — not "ownership *now* lives on the executor instead of a plugin-local map". When a contrast genuinely aids understanding (a non-obvious choice between live alternatives), frame it against the alternative as a standing fact ("stored on the executor, NOT the tool plugin, so it survives an HMR reload"), not against the codebase's past. The same rule governs review-fix commits: the *commit message* records what the review caught; the *code comment* it touches states only the resulting truth. RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. **Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. `pnpm run verify-md-wrap` (part of `doc-sync`) enforces this across `README.md`, `docs/**/*.md`, `packages/*/README.md`, and `AGENTS.md` / `packages/AGENTS.md`; `pnpm run verify-md-links` (also part of `doc-sync`) checks that every relative cross-link in those files resolves. From 07048983e09644c237d9735582ebb2c9b4d94a5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:24:37 +0800 Subject: [PATCH 27/87] build(doc-sync): add verify-type-equiv gate for verbatim type pastes Introduce a `ts type-equiv` Markdown fence: a verbatim paste of a source type definition that `scripts/verify-type-equiv.ts` drift-checks against the source symbol via the TypeScript parser, with provenance in a central `scripts/type-equiv.manifest.json` kept 1:1 with the blocks. doc-typecheck recognizes the same fence, skips compiling it (not standalone-compilable), and excludes it from the opt-out ratio. Wired into the `doc-sync` chain. --- package.json | 3 +- scripts/doc-typecheck.ts | 54 +++++--- scripts/type-equiv.manifest.json | 41 ++++++ scripts/verify-type-equiv.ts | 208 +++++++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 scripts/type-equiv.manifest.json create mode 100644 scripts/verify-type-equiv.ts diff --git a/package.json b/package.json index 50319023cf..e171048edf 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,11 @@ "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 10068eb792..3af95e2d0e 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -8,7 +8,11 @@ * build is required first). A block that is a deliberate sketch rather than * compilable code opts out with an explicit ` ```ts ignore-check ` info string * — the opt-out is visible in the source, and this script reports the ratio so - * the escape hatch can't quietly become the norm. + * the escape hatch can't quietly become the norm. A third info string, + * ` ```ts type-equiv `, marks a verbatim paste of a source type definition that + * `scripts/verify-type-equiv.ts` drift-checks against the source symbol; it is + * skipped here and EXCLUDED from the opt-out ratio (a separately-checked + * category, not an unchecked sketch). * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -20,23 +24,35 @@ import { glob } from 'node:fs/promises' const root = resolve(import.meta.dirname, '..') +/** + * How a fenced block participates in this gate: + * - `check` (` ```ts `) — compiled. + * - `ignore` (` ```ts ignore-check `) — a deliberate sketch; skipped, and + * counted in the opt-out ratio so the escape hatch can't quietly take over. + * - `type-equiv` (` ```ts type-equiv `) — a verbatim paste of a source type + * definition, drift-checked by `scripts/verify-type-equiv.ts` against the + * source symbol. Skipped HERE (it is not standalone-compilable — no imports) + * and EXCLUDED from the opt-out ratio: it is a separate fully-checked + * category, not an unchecked sketch. + */ +type BlockKind = 'check' | 'ignore' | 'type-equiv' + /** One extracted code block. */ interface Block { file: string /** 1-based line of the opening fence. */ line: number - /** `true` when the fence is ` ```ts ignore-check ` (skip compilation). */ - ignored: boolean + kind: BlockKind code: string } -/** Extract every ```ts / ```ts ignore-check block from one Markdown file. */ +/** Extract every ```ts / ```ts ignore-check / ```ts type-equiv block from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') const lines = text.split('\n') const file = relative(root, absPath) const blocks: Block[] = [] - let open: { line: number; ignored: boolean; body: string[] } | null = null + let open: { line: number; kind: BlockKind; body: string[] } | null = null lines.forEach((raw, i) => { const fence = /^```(\s*)(\S.*)?$/.exec(raw) @@ -46,15 +62,18 @@ function extractBlocks(absPath: string): Block[] { } if (open) { // closing fence - blocks.push({ file, line: open.line, ignored: open.ignored, code: open.body.join('\n') }) + blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') }) open = null return } // opening fence — only care about ts blocks const info = (fence[2] ?? '').trim() - if (info === 'ts' || info === 'ts ignore-check') { - open = { line: i + 1, ignored: info === 'ts ignore-check', body: [] } - } + const kind: BlockKind | null = + info === 'ts' ? 'check' + : info === 'ts ignore-check' ? 'ignore' + : info === 'ts type-equiv' ? 'type-equiv' + : null + if (kind) open = { line: i + 1, kind, body: [] } }) return blocks } @@ -106,8 +125,13 @@ for (const pattern of markdownGlobs) { files.sort() const all = files.flatMap(extractBlocks) -const checked = all.filter(b => !b.ignored) -const ignored = all.filter(b => b.ignored) +const checked = all.filter(b => b.kind === 'check') +const ignored = all.filter(b => b.kind === 'ignore') +// `type-equiv` blocks are verified by verify-type-equiv.ts, not here: neither +// compiled nor counted toward the opt-out ratio (they are a separate +// fully-checked category, not an unchecked sketch). The ratio's denominator is +// therefore the compile-eligible blocks only. +const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { console.log('doc-typecheck: no ts code blocks to check.') @@ -139,11 +163,11 @@ try { process.exit(1) } - const ratio = ignored.length / all.length - console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out).`) + const ratio = ignored.length / ratioDenominator + console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${all.length - ratioDenominator} type-equiv (checked by verify-type-equiv).`) // Guard against the escape hatch becoming the norm. - if (all.length >= 4 && ratio > 0.5) { - console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${all.length}). Make them compile or delete them.`) + if (ratioDenominator >= 4 && ratio > 0.5) { + console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) process.exit(1) } } finally { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json new file mode 100644 index 0000000000..5ea2916cd4 --- /dev/null +++ b/scripts/type-equiv.manifest.json @@ -0,0 +1,41 @@ +{ + "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "entries": [ + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/agent/src/types.ts" }, + + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/session/src/types.ts" }, + + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/session/src/types.ts" }, + + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/tools/src/index.ts" }, + + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/src/types.ts" } + ] +} diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts new file mode 100644 index 0000000000..5de48b97f9 --- /dev/null +++ b/scripts/verify-type-equiv.ts @@ -0,0 +1,208 @@ +/** + * Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a + * VERBATIM copy of the source type definition it documents. + * + * The core-data-structures docs paste real type definitions so a reader sees + * the exact shape. A paste drifts the moment source changes — this script is + * the drift guard. For each block it extracts the documented symbol's + * declaration from source via the TypeScript compiler API, whitespace- + * normalizes both the source text and the block, and asserts they are equal. + * + * Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`), + * NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script + * enforces a 1:1 correspondence — every type-equiv block in the docs has + * exactly one manifest entry (keyed by doc + declared symbol), and every + * manifest entry resolves to exactly one block. An orphan on either side fails, + * so a block can never be silently unchecked and an entry can never rot. + * + * doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it + * (it is not standalone-compilable and is not counted in the opt-out ratio); + * the two scripts share the fence, this one owns the verification. + * + * Run: `tsx scripts/verify-type-equiv.ts`. + */ + +import { readFileSync, existsSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') + +/** One manifest entry: a documented type-equiv block and its source symbol. */ +interface ManifestEntry { + /** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */ + doc: string + /** The declared symbol the block must match (e.g. `SessionEvent`). */ + symbol: string + /** Source file (repo-relative) that exports the symbol. */ + source: string +} + +/** One extracted ` ```ts type-equiv ` block. */ +interface EquivBlock { + doc: string + /** 1-based line of the opening fence (for diagnostics). */ + line: number + /** Symbol name parsed from the block's declaration. */ + symbol: string + /** Block body (the pasted declaration). */ + code: string +} + +/** Collapse a declaration to its structural form for comparison: drop comments + * (block + line), then collapse all whitespace runs to single spaces. This lets + * a doc block show a CLEAN definition (without source's verbose inline JSDoc) + * while still guaranteeing the field shapes match — drift in a field name or + * type fails; a reworded inline comment does not. Adequate for our own type + * source (no string literal contains `//` or `/* *​/`); not a general tokenizer. */ +function normalize(code: string): string { + return code + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1') + .replace(/\s+/g, ' ') + .trim() +} + +/** Strip a leading `export ` / `export default ` modifier — the doc block shows + * the bare declaration, the source carries the export modifier. */ +function stripExport(code: string): string { + return code.replace(/^export\s+(default\s+)?/, '') +} + +/** Parse the declared symbol name from a type-equiv block body. */ +function blockSymbol(code: string): string | null { + const m = /(?:export\s+(?:default\s+)?)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) + return m?.[1] ?? null +} + +/** Extract every ` ```ts type-equiv ` block from one Markdown file. */ +function extractEquivBlocks(docRel: string): EquivBlock[] { + const text = readFileSync(resolve(root, docRel), 'utf8') + const lines = text.split('\n') + const blocks: EquivBlock[] = [] + let open: { line: number; body: string[] } | null = null + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] ?? '' + const fence = /^```(\s*)(\S.*)?$/.exec(raw) + if (!fence) { + if (open) open.body.push(raw) + continue + } + if (open) { + const code = open.body.join('\n') + const symbol = blockSymbol(code) + if (!symbol) { + throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`) + } + blocks.push({ doc: docRel, line: open.line, symbol, code }) + open = null + continue + } + if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] } + } + if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`) + return blocks +} + +/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or + * null when the symbol is not declared there. Uses the TS parser so it spans + * interfaces, type aliases (including mapped/generic ones), classes, and enums + * uniformly, and excludes the leading JSDoc (getStart skips leading trivia) + * while keeping inline member comments. */ +function sourceDeclaration(sourceRel: string, symbol: string): string | null { + const abs = resolve(root, sourceRel) + const text = readFileSync(abs, 'utf8') + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + for (const stmt of sf.statements) { + const named = + ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) + || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt) + if (named && stmt.name?.text === symbol) { + return stripExport(stmt.getText(sf)) + } + } + return null +} + +const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8') +const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] } +const entries = manifest.entries + +// Key a block/entry by doc + symbol (a symbol may be documented in more than one +// doc, but at most once per doc). +const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}` + +// Collect every type-equiv block across the docs the manifest references. +const docFiles = [...new Set(entries.map(e => e.doc))] +const missingDocs = docFiles.filter(d => !existsSync(resolve(root, d))) +const blocks: EquivBlock[] = docFiles.filter(d => existsSync(resolve(root, d))).flatMap(extractEquivBlocks) + +const errors: string[] = [] +for (const d of missingDocs) errors.push(`manifest references ${d}, which does not exist`) + +// Duplicate-block guard: the same symbol twice in one doc is ambiguous. +const blockByKey = new Map() +for (const b of blocks) { + const k = keyOf(b) + const prior = blockByKey.get(k) + if (prior) { + errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`) + continue + } + blockByKey.set(k, b) +} + +// Duplicate-entry guard in the manifest. +const entryByKey = new Map() +for (const e of entries) { + const k = keyOf(e) + if (entryByKey.has(k)) { + errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`) + continue + } + entryByKey.set(k, e) +} + +// 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block). +for (const b of blocks) { + if (!entryByKey.has(keyOf(b))) { + errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`) + } +} +for (const e of entries) { + if (!blockByKey.has(keyOf(e))) { + errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`) + } +} + +// Verbatim check: each matched block must equal its source declaration. +let verified = 0 +for (const e of entries) { + const b = blockByKey.get(keyOf(e)) + if (!b) continue // already reported as an orphan entry + const decl = sourceDeclaration(e.source, e.symbol) + if (decl === null) { + errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) + continue + } + if (normalize(decl) !== normalize(stripExport(b.code))) { + errors.push( + `DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n` + + ` source: ${normalize(decl)}\n` + + ` doc: ${normalize(stripExport(b.code))}`, + ) + continue + } + verified++ +} + +if (errors.length === 0) { + console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`) + process.exit(0) +} + +console.error('verify-type-equiv: type-equiv verification failed:') +for (const e of errors) console.error(` ${e}`) +console.error(`\n(checked ${blocks.length} block(s) across ${docFiles.map(d => relative(root, resolve(root, d))).length} doc(s); manifest at scripts/type-equiv.manifest.json)`) +process.exit(1) From 0f7abc9808565b49822c3957e0ad588b1113fd12 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:24:56 +0800 Subject: [PATCH 28/87] docs(core-data-structures): catalog the core data structures A new docs/core-data-structures/ folder: a self-contained core.md defining what counts as a "core" data structure (the agent-loop spine) and covering the spine vocabulary, plus per-seam sub-pages (llm-streaming, session, persistence, tools, bash). Type definitions are pasted verbatim via `ts type-equiv` blocks and drift-checked by verify-type-equiv. Cross-linked from architecture.md; the `ts type-equiv` mechanics are documented in development.md. --- docs/architecture.md | 2 + docs/core-data-structures/bash.md | 123 +++++++++ docs/core-data-structures/core.md | 300 +++++++++++++++++++++ docs/core-data-structures/llm-streaming.md | 66 +++++ docs/core-data-structures/persistence.md | 63 +++++ docs/core-data-structures/session.md | 119 ++++++++ docs/core-data-structures/tools.md | 112 ++++++++ docs/development.md | 13 +- 8 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 docs/core-data-structures/bash.md create mode 100644 docs/core-data-structures/core.md create mode 100644 docs/core-data-structures/llm-streaming.md create mode 100644 docs/core-data-structures/persistence.md create mode 100644 docs/core-data-structures/session.md create mode 100644 docs/core-data-structures/tools.md diff --git a/docs/architecture.md b/docs/architecture.md index 9a81b1c6d3..799d543ded 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,8 @@ The harness core is deliberately tiny: a handful of abstract services plus one c Requirement context: [Coding Harness MVP 需求分析][mvp-doc]. +For a catalog of the **data structures** this architecture moves around — the core vocabulary types, their literal shapes, and the seam types grouped by capability — see [core-data-structures/](core-data-structures/core.md). This document covers behavior; that one covers the types. + **Contents:** [Layering](#layering) · [Service map](#service-map) · [Capability seams](#capability-seams-interface--implementation--consumer) · [The vocabulary (dsh-llm)](#the-vocabulary-dsh-llm) · [Event-sourced sessions](#event-sourced-sessions-dsh-session) · [Prompt assembly](#prompt-assembly-dsh-system-prompt) · [Tool pipeline](#tool-pipeline-dsh-tools) · [Agents and the loop](#agents-dsh-agent-and-the-loop-dsh-agent-loop) ([lifecycle](#loop-lifecycle-session--turn--step), [event taxonomy](#event-taxonomy), [waterfall semantics](#cordis-waterfall-semantics-important)) · [Plugin sanity checklist](#plugin-sanity-checklist) · [Extension cookbook](#extension-cookbook) · [Deferred work](#deferred-work-todo) [microkernel-doc]: https://trtgsjkv6r.feishu.cn/wiki/VS9Lw1kQki6mDJk2UHocyuphnsc diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md new file mode 100644 index 0000000000..fc24bbd386 --- /dev/null +++ b/docs/core-data-structures/bash.md @@ -0,0 +1,123 @@ +# Bash Executor + +The bash execution seam — the canonical [capability seam](../rfc/implemented/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. + +Source: [`packages/bash/src/types.ts`](../../packages/bash/src/types.ts) + +## Request vs. spec: the `resolve()` split + +The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`, filled from config) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory came from. + +```ts type-equiv +interface BashExecRequest { + command: string + /** Working directory override (default: implementation-configured). */ + workdir?: string | undefined + /** Timeout override in milliseconds (implementations cap it). */ + timeoutMs?: number | undefined + /** Abort signal — implementations kill the command when it fires. */ + signal?: AbortSignal | undefined + /** + * Opaque OWNER token for a background task — the consumer's isolation key + * (the tool layer passes the owning agent's `session.header.id`). The + * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; + * the executor itself NEVER interprets it (no access policy lives in the + * seam — that is the consumer's job). Absent for foreground runs and for an + * ownerless background start (a non-agent caller). + */ + owner?: string | undefined +} +``` + +```ts type-equiv +interface BashExecSpec { + command: string + workdir: string + timeoutMs: number + /** Abort signal — implementations kill the command when it fires. */ + signal?: AbortSignal | undefined + /** + * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` + * being required on the resolved spec): {@link BashExecutor.resolve} carries + * the request's `owner` through, defaulting a missing one to `undefined`. A + * required field makes a forgotten owner a VISIBLE `undefined` rather than a + * silently-absent property that yields an unowned (cross-session-readable) + * task. `start()` stores it; `run()` (foreground) ignores it. + */ + owner: string | undefined +} +``` + +The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. + +## Foreground runs: `BashRunResult` + +The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. + +```ts type-equiv +interface BashRunResult { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + /** True when the executor's own timeout killed the command. */ + timedOut: boolean + /** True when the caller's AbortSignal killed the command. */ + aborted: boolean + /** The effective timeout applied to this run (after defaulting/capping). */ + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} +``` + +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: + +```ts type-equiv +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## Background tasks: `BashTask` + +A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects. + +```ts type-equiv +interface BashTask { + readonly id: string + readonly command: string + status: BashTaskStatus + /** Exit code once finished (null = killed by signal / still running). */ + exitCode: number | null + /** Terminating signal name, when signal-killed. */ + signal: NodeJS.Signals | null + /** Resolves when the underlying process closes (never rejects). */ + readonly done: Promise +} +``` + +`readOutput()` returns an incremental `BashTaskRead` — the output produced since the previous read, with a `lossy` flag when truncation dropped unread bytes: + +```ts type-equiv +interface BashTaskRead { + task: BashTask + /** Output produced since the previous read (stderr in a marked section). */ + delta: string + /** True when truncation dropped unread bytes the delta cannot include. */ + lossy: boolean + /** Full stdout spill file, when stdout truncation occurred and a safe path is available. */ + stdoutSpillPath?: string + /** Full stderr spill file, when stderr truncation occurred and a safe path is available. */ + stderrSpillPath?: string +} +``` + +## The service + +`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/src/index.ts`](../../packages/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md new file mode 100644 index 0000000000..3743e6e0df --- /dev/null +++ b/docs/core-data-structures/core.md @@ -0,0 +1,300 @@ +# Core Data Structures + +This folder catalogs the **data structures** of the DeepSeek Harness — what each core type represents, its literal shape, and where the full detail lives. It complements [architecture.md](../architecture.md), which describes *behavior* (the service map, the session/turn/step lifecycle, the event taxonomy); this page describes the *vocabulary* that behavior moves around. + +## What counts as "core" + +The harness is a microkernel: a tiny core plus many plugins. Most types belong to one plugin or one capability. A handful, though, are the **spine** — the language the agent loop and its events traffic in on *every* turn, no matter which optional plugins are loaded. Those are "core". + +Precisely, a data structure is **core** if either: + +1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or** +2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*). + +Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallPresentation` vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below. + +| Sub-page | Owns | +|---|---| +| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | +| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | +| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | +| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall | +| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | + +> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. + +## The `…Map → derived-union` pattern + +Almost every extensible sum type in the harness follows one shape: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. + +```ts ignore-check +// The pattern, schematically: +interface ThingMap { + 'a': { kind: 'a'; /* … */ } + 'b': { kind: 'b'; /* … */ } +} +type ThingKind = keyof ThingMap // 'a' | 'b' +type Thing = ThingMap[keyof ThingMap] // the discriminated union + +// A plugin extends it without touching the source package: +declare module '@deepseek-ai/dsh-llm' { + interface ThingMap { + 'c': { kind: 'c'; /* … */ } + } +} +``` + +Six canonical maps use this pattern; a plugin author extends these: + +| Map | Package | Derives | Catalog | +|---|---|---|---| +| `ContentBlockMap` | dsh-llm | `ContentBlock` | [below](#content-blocks-and-messages) | +| `MessageSourceMap` | dsh-llm | `MessageSource` | [below](#content-blocks-and-messages) | +| `FinishReasonMap` | dsh-llm | `FinishReason` | [below](#the-model-request-and-result) | +| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | +| `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | +| `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | + +Two large discriminated unions are the ones consumers `switch` over most: **`StreamChunk`** (the streaming protocol) and **`SessionEvent`** (the log entry). Per the repo convention, `switch` on the tag — don't chain `if`s — so each arm narrows and a typo'd tag fails to compile. + +## Branded IDs + +IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. + +Source: [`packages/llm/src/brand.ts`](../../packages/llm/src/brand.ts) + +```ts type-equiv +type Branded = string & { readonly [BRAND]: B } +``` + +The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. + +## Content blocks and messages + +A conversation is `Message`s; a message is an array of typed **content blocks**. The block union derives from `ContentBlockMap`. + +Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) + +```ts type-equiv +interface ContentBlockMap { + 'text': TextBlock + '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]`. + +A `Message` is a role plus blocks: + +```ts type-equiv +interface Message { + role: 'system' | 'user' | 'assistant' + content: ContentBlock[] +} +``` + +Where a message came from is itself a merge-extensible sum type: + +```ts type-equiv +interface MessageSourceMap { + user: { kind: 'user' } + plugin: { kind: 'plugin'; plugin: string } + agent: { kind: 'agent'; agentId: string } +} +``` + +## Streaming + +Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`. + +The full union, the adapter contract (usage-before-finish, raw-JSON tool arguments, the two sanctioned error paths), and `BlockAssembler` live on **[llm-streaming.md](llm-streaming.md)**. + +## The model request and result + +One model call is a fully-assembled `GenerateOptions`; the non-streaming result is `GenerateResult`. + +Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) + +```ts type-equiv +interface GenerateOptions { + model: string + messages: Message[] + /** System prompt text (adapters map to the provider's system slot). */ + system?: string + /** Tool schemas (adapters map to the provider's `tools` field). */ + tools?: ToolSchema[] + /** Assistant prefix continuation (prefill). */ + prefill?: ContentBlock[] + temperature?: number + maxTokens?: number + /** + * Stop sequences: generation halts as soon as the model produces any one of + * these strings (adapters map to the provider's stop field, e.g. OpenAI + * `stop`). The stop string itself is not included in the output. + */ + stop?: string[] + signal?: AbortSignal +} +``` + +```ts type-equiv +interface GenerateResult { + message: Message + usage?: TokenUsage + finish: FinishReason +} +``` + +Why a model response stopped is a merge-extensible reason: + +```ts type-equiv +interface FinishReasonMap { + 'stop': { kind: 'stop' } + 'tool-calls': { kind: 'tool-calls' } + 'max-tokens': { kind: 'max-tokens' } + 'aborted': { kind: 'aborted' } + 'error': { kind: 'error'; message: string; code?: string } +} +``` + +`FinishReason = FinishReasonMap[keyof FinishReasonMap]`. `TokenUsage` (per-call accounting with disjoint cache fields) is detailed on [llm-streaming.md](llm-streaming.md). + +`GenerateOptions.tools` carries `ToolSchema` — the JSON-schema description of a tool, as sent to the model. It is declared in dsh-llm (not dsh-tools) precisely because it is part of the request the loop assembles every step: + +```ts type-equiv +interface ToolSchema { + name: string + description: string + /** JSON Schema object for the arguments. */ + parameters: Record + strict?: boolean +} +``` + +The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). + +## Sessions + +A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`: + +Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) + +```ts type-equiv +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } +}[T] +``` + +The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `usage`, `error`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. + +## The agent handle + +`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. + +Source: [`packages/agent/src/types.ts`](../../packages/agent/src/types.ts) + +```ts type-equiv +interface Agent { + readonly id: AgentId + readonly options: AgentOptions + readonly session: Session + readonly status: AgentStatus + + /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */ + send(content: ContentBlock[], options?: SendOptions): void + + /** + * Steer a running turn: content is injected between steps of the current + * turn. When idle, behaves like {@link send}. + */ + steer(content: ContentBlock[], options?: SendOptions): void + + /** + * Inject in-session context (file-change notices, skill content, cron + * notifications, …): appends a `context/message` session event the next model + * request sees at its chronological position, rendered as tagged synthetic + * context rather than a user prompt. Does not run the model. + * + * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; + * an inject while idle wraps its `context/message` in a one-shot `injection` + * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for + * durability, so every event stays inside a turn and a persistence backend + * never loses a between-turn notice. The idle checkpoint is fire-and-forget + * (inject is synchronous): a failing flush is reported via `agent/error` + * (step `0`) and the logger, never thrown into the caller. + * + * Live-adapter review has validated the tagged-envelope rendering against + * current DeepSeek behavior; provider-specific mismatches belong in that + * adapter, not in the canonical session vocabulary. + */ + inject(content: ContentBlock[], options?: SendOptions): void + + /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ + abort(reason?: string): void + + /** + * Cancel ALL pending work for the agent — the narrower {@link abort} kills + * only the in-flight step. `cancel()`: + * + * - clears the queued FIFO (un-started prompts never run) and the steering + * FIFO (steering for the cancelled turn is dropped, not re-enqueued); + * - aborts the in-flight step if one is running (the turn ends `aborted`); + * - drops a turn that is about to start (a `cancel()` landing in the + * pre-step window — after a `send()` queued but before the loop flips to + * `running`, or after `running` is emitted but before the first step) so + * that queued prompt does not run and cannot be batched into the cancelled + * turn. + * + * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. + * `cancel()` on an idle agent with nothing queued or running is a safe no-op + * — it does NOT arm anything that would drop a later legitimate prompt. + */ + cancel(reason?: string): void + + /** + * Resolve once the agent has reached quiescence after settling out of + * `running`, or immediately if it is already idle with no queued work. The + * quiescence signal a teardown awaits: `agent.abort()` then + * `await agent.whenIdle()` guarantees queued/running work has fully stopped + * before the caller proceeds (a closing ACP connection, a disposing UI + * plugin), rather than returning while the driver is still streaming or about + * to start a queued turn. + * + * "Quiescence", not merely "status changed": a disposed agent emits + * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop + * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop + * to actually exit (the implementation chains the loop-exit promise), not just + * observe the status flip. A mid-step disposal that never reaches `idle` still + * unblocks the await this way. + * + * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing + * the agent down. A consumer that owns the agent's lifecycle disposes it + * separately. + */ + whenIdle(): Promise + + // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. + // The intended shape: a creation option referencing a parent agent + // (fork = seed the child Session with the parent's event log; spawn = + // fresh Session), with the child returned as an Agent handle so steer() + // and event subscription work uniformly. See docs/architecture.md. +} +``` + +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). + +## `ToolDefinition` + +The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. + +Its full fields, the `defineTool`/`SchemaSpec`/`InferArgs` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md new file mode 100644 index 0000000000..2e3d4a0838 --- /dev/null +++ b/docs/core-data-structures/llm-streaming.md @@ -0,0 +1,66 @@ +# LLM Streaming + +The wire-level streaming vocabulary of [dsh-llm](../../packages/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. + +Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) + +## `StreamChunk` — the raw protocol + +A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it. + +```ts type-equiv +type StreamChunk = + | { type: 'block-start'; index: number; blockType: ContentBlockType } + | { type: 'text-delta'; index: number; text: string } + | { type: 'reasoning-delta'; index: number; text: string } + | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } + | { type: 'block-end'; index: number; block: ContentBlock } + | { type: 'usage'; usage: TokenUsage } + | { type: 'finish'; reason: FinishReason } +``` + +## The adapter contract + +Every adapter MUST obey these, and every consumer may rely on them: + +- **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. +- **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. +- **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop translates a finish-error/aborted into a turn error — it never logs a normal completed assistant message for a failed step. + +This contract is why two adapters exist as a deliberate pair: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (the same endpoint through `@earendil-works/pi-ai`). Two independent internals over one contract is what pinned the protocol down — the library-backed adapter can't throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not. + +## `TokenUsage` + +Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. + +```ts type-equiv +interface TokenUsage { + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} +``` + +## `BlockAssembler` + +`BlockAssembler` ([`packages/llm/src/assembler.ts`](../../packages/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s and a final `Message`. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this. + +## The seam + +`LlmAdapter` is the provider seam: subclass, implement `stream()`, register with `ctx.llm.registerAdapter(models, adapter)`. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()` / `streamBlocks()` / `generate()`) and the `llm/stream` waterfall are described in [architecture.md § The vocabulary](../architecture.md#the-vocabulary-dsh-llm). + +`ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: + +```ts type-equiv +interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock + 'image': ImageBlock +} +``` + +See [core.md § Content blocks and messages](core.md#content-blocks-and-messages) for the block interfaces. diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md new file mode 100644 index 0000000000..8bc132ffb2 --- /dev/null +++ b/docs/core-data-structures/persistence.md @@ -0,0 +1,63 @@ +# Session Persistence + +The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. + +The seam is a textbook [capability seam](../rfc/implemented/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/2026-06-14-session-persistence.md). + +## The flush checkpoint + +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. Flush is `ctx.parallel` (awaited): a turn's events are durably committed before the next turn starts, and the turn boundary is the commit boundary. A rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the commit boundary), so the backend keeps its buffered events for the next flush. + +## Crash recovery preserves an interrupted turn + +A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). + +## `SessionHeader` — metadata beside the log + +Per-session metadata travels **separately** from the event log: format version, cwd, and lineage are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. + +Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) + +```ts type-equiv +interface SessionHeader { + /** On-disk format version; a persistence backend rejects unknown versions. */ + version: number + /** The session's id (mirrors the {@link Session}'s id). */ + id: SessionId + /** Unix epoch milliseconds when the session was created. */ + createdAt: number + /** Absolute working directory the session was created in (if any). */ + cwd?: string + /** The session this one was forked from (seed lineage), if any. */ + parentSession?: SessionId +} +``` + +## `CreateSessionOptions` — seeding and metadata + +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. + +```ts type-equiv +interface CreateSessionOptions { + /** Events to seed the new session with (replay/fork). */ + seed?: SessionEvent[] + /** + * Creation metadata. The store fills in `version`/`id` and defaults + * `createdAt` to now; the caller supplies the storage-level fields (validated + * absolute `cwd`, `parentSession` lineage, and — when reconstructing a + * persisted session — the original `createdAt` to preserve it). + */ + meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } +} +``` + +Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. + +## The backends + +Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: + +- **[dsh-session-persistence-jsonl](../../packages/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. +- **[dsh-session-persistence-sqlite](../../packages/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. + +Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md new file mode 100644 index 0000000000..5803641c27 --- /dev/null +++ b/docs/core-data-structures/session.md @@ -0,0 +1,119 @@ +# Sessions + +The in-memory, event-sourced model of [dsh-session](../../packages/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). + +Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) + +## `SessionEventMap` — the event vocabulary + +The append-only event types. Merge-extensible: a plugin (e.g. compaction) declares extra event types via declaration merging. + +```ts type-equiv +interface SessionEventMap { + 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/end': { turn: number; reason: TurnEndReason } + 'step/start': { turn: number; step: number } + 'step/end': { turn: number; step: number } + /** A user-visible prompt (queued message drained at turn start). */ + 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * In-session context injection (file-change notices, subdir AGENTS.md, + * skill content, cron notifications, …). Rendered into the derived history + * as tagged synthetic context — NOT a user prompt. + */ + 'context/message': { content: ContentBlock[]; source: MessageSource } + /** Raw stream chunk — token-level replay fidelity. */ + 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } + /** Assembled assistant message for one step (derived history uses this). */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[] } + 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } + /** Steering content injected between steps of a running turn. */ + 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } + 'usage': { turn: number; step: number; usage: TokenUsage } + 'error': { turn: number; step: number; message: string; code?: string } +} +``` + +## `SessionEvent` — one log entry + +A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. + +```ts type-equiv +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } +}[T] +``` + +`SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`. + +## Derived history: `deriveMessages()` + +`Session.deriveMessages()` projects the event log into the `Message[]` the model sees. The projection rules: + +- `user/message` → a user message. +- `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). +- `tool/result` → a user message carrying a `tool-result` block. +- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (``) at their chronological position — the "system-reminder" pattern; the model distinguishes them from real prompts by the envelope. + +Everything else (`turn/*`, `step/*`, `usage`, `error`) is structural/telemetry and does not project into a message. + +## What started a turn: `TurnTriggerMap` + +```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 + * (`turn/start` → `context/message` → `turn/end`) so every event in the log + * stays turn-enclosed — the durability/replay boundary is the turn, and a + * bare event between turns would otherwise be indistinguishable from a crash + * tail on reload. + */ + injection: { kind: 'injection'; source: MessageSource } +} +``` + +## Why a turn ended: `TurnEndReasonMap` + +```ts type-equiv +interface TurnEndReasonMap { + completed: { kind: 'completed' } + aborted: { kind: 'aborted'; reason?: string } + error: { kind: 'error'; message: string; code?: string } + disposed: { kind: 'disposed' } + 'max-tokens': { kind: 'max-tokens' } + /** + * The turn never ended on its own: the process crashed mid-turn and a + * persistence backend later closed the orphaned (open) turn on reload so the + * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no + * loop ever emits this. Its events are real (they were durably appended before + * the crash) and are PRESERVED, not discarded: a single turn can be huge in a + * long-horizon task (many steps, large tool output), so truncating it would + * lose real work. The marker records that the turn was cut short, not that the + * model completed it. See the session-persistence RFC. + */ + interrupted: { kind: 'interrupted' } +} +``` + +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. + +## The turn-enclosure invariant + +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/2026-06-15-turn-enclosure-invariant.md). + +## Durability contract + +What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting the invariants plugin checks, is a breaking change to the on-disk format. + +The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md new file mode 100644 index 0000000000..3fdc70c1d5 --- /dev/null +++ b/docs/core-data-structures/tools.md @@ -0,0 +1,112 @@ +# Tools + +The tool pipeline of [dsh-tools](../../packages/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. + +Source: [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts) · [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) + +## `ToolDefinition` — a registered tool + +A `ToolSchema` (the model-facing fields) plus the `execute` function and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`presentCall`/`presentResult` must never leak into a model request. + +```ts type-equiv +interface ToolDefinition extends ToolSchema { + execute(args: unknown, exec: ToolExecution): Promise + /** + * Optional: how to present the PENDING state of one call in a UI, derived + * from the call's `args` (parsed arguments, `unknown` — the tool validates/ + * narrows its own input). Returning `undefined` (or omitting the method) tells + * a UI to fall back to a generic presentation (title = tool name, raw args as + * input). Pure and side-effect-free: a UI may call it during live streaming + * AND a session-log replay, so it must depend only on `args`. + */ + presentCall?(args: unknown): ToolCallPresentation | undefined + /** + * Optional: how to present the COMPLETED state, given the same `args` and the + * `result` (`execute`'s content + whether it errored). Returning `undefined` + * (or omitting the method) tells a UI to keep the pending title and render the + * raw result content. Pure and side-effect-free for the same replay reason. + */ + presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined +} +``` + +`execute` receives `args: unknown` — a raw `ToolDefinition` validates its own input. First-party tools don't write that by hand; they use `defineTool`, which validates and narrows for them. + +## The typed schema DSL + +Plugin authors write per-property specs with a boolean `required: true`, and a type-level helper maps the spec to the `execute` argument type — zero casts. The DSL is *machinery that types* `ToolDefinition`; it is intentionally a sub-page detail, not core. + +Source: [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) + +```ts type-equiv +interface SchemaProp { + type: SchemaType + /** Per-property required flag (NOT the JSON Schema top-level required array). */ + required?: true + /** Human-readable description, surfaced in the JSON Schema as well. */ + description?: string + /** Enum of allowed values (strings only). */ + enum?: string[] + /** Default value. */ + default?: unknown + /** Nested properties for type: 'object'. */ + properties?: SchemaSpec + /** Items schema for type: 'array'. */ + items?: SchemaProp +} +``` + +```ts type-equiv +type SchemaSpec = Record +``` + +`SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional: + +```ts type-equiv +type InferArgs = Simplify< + & { [K in RequiredKeys]: InferPropValue } + & { [K in Exclude>]?: InferPropValue } +> +``` + +`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. + +## Execution: the `tools/execute` waterfall shapes + +`ctx.tools.execute()` runs each call through the `tools/execute` waterfall — the single seam where sandbox, permission, hook, and plan-mode plugins wrap or veto. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`. + +```ts type-equiv +interface ToolExecution { + callId: CallId + name: string + /** Parsed JSON arguments (unknown — tools validate their own input). */ + arguments: unknown + /** The agent on whose behalf the call runs (set by the agent loop). */ + agent?: Agent + signal?: AbortSignal +} +``` + +```ts type-equiv +interface ToolExecutionResult { + callId: CallId + content: ContentBlock[] + isError: boolean + /** + * Set when the call failed with a {@link HarnessError}: machine-routable + * `{ name, code }` for retry/sandbox plugins and replay. The model-facing + * text in `content` is always present; this is extra structure for code. + */ + error?: ToolErrorInfo +} +``` + +A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly around your own logic), or return a `ToolExecutionResult` without calling `next()` to veto. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. + +## Tool-presentation UI vocabulary + +How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill). + +> These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative. + +The full presentation field docs live in [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). diff --git a/docs/development.md b/docs/development.md index 2270aa0436..d5796d32a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,7 +95,8 @@ pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown -pnpm run doc-sync # doc-typecheck, event taxonomy, and markdown wrap verification +pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type +pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap/link, and type-equiv verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # build declarations and JS bundles @@ -128,6 +129,16 @@ Use one of three comment tags to flag known issues in the code, ordered by urgen Pick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe. +## Documenting types verbatim (`ts type-equiv`) + +The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: + +```json +{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" } +``` + +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. + ## Architecture context Read `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points. From ea5697e35489053672477ee22ded8162f681bb28 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:25:08 +0800 Subject: [PATCH 29/87] docs(review): require keeping the core-data-structures catalog in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a standing instruction in AGENTS.md and a hard-blocker check in the dsh-code-review skill: a change that adds/removes/reshapes a type the catalog documents must update the catalog (prose + verbatim block + manifest) in the same diff. verify-type-equiv catches a drifted paste but cannot flag a new core type that went undocumented — that judgment is on author and reviewer. --- .agents/skills/dsh-code-review/SKILL.md | 7 ++++--- AGENTS.md | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index ac59587e4d..0b3a28794f 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -31,9 +31,10 @@ These define the conventions and gates this repo is checked against, and they ar These come straight from the source docs above. They are not discretionary; absence is a blocking gap. -1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #3) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. -2. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -3. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-event-taxonomy + verify-md-wrap), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the event-taxonomy table, and markdown wrapping; prose drift (check #1) is *additional* manual review on top of it, not covered by it. +1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. +2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. +3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the event-taxonomy table, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/AGENTS.md b/AGENTS.md index 424780bf74..00e83edb6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,9 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, and checks that every relative Markdown cross-link resolves — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. + +**The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. **Document the CURRENT state — the "what" and "why" — never the PROCESS or HISTORY of how it got there.** A comment, JSDoc, or doc paragraph describes what the code *is* and why it is that way, as if it had always been so. Do NOT narrate the change that produced it: no "previously X, now Y", "changed from", "used to", "this replaces", "the old map", "renamed", "moved here", "as of this PR", or "(was …)". **In particular, NEVER name the change unit a reader cannot see — the PR, commit, or stack position that introduced the code — in a comment, JSDoc, OR a test name/description.** A `// (PR D's per-agent teardown)` aside, a `* Tests for the cancel primitive (PR C).` module doc, or an `it('… identity no longer matters')` title that only makes sense relative to a prior design are all the same violation: the reader of the current tree has no "PR D" or "old design" to anchor against, and the reference rots the moment the stack merges. Name the *mechanism* (`the session's AgentHandle teardown`), not the PR. Such phrasing rots the instant the next change lands, and a reader of the current code does not need the diff narrated in prose — that belongs in the commit message, the PR description, or an RFC (the durable home for "why we moved away from X"). Write "the owner token lives on the task in the executor" — not "ownership *now* lives on the executor instead of a plugin-local map". When a contrast genuinely aids understanding (a non-obvious choice between live alternatives), frame it against the alternative as a standing fact ("stored on the executor, NOT the tool plugin, so it survives an HMR reload"), not against the codebase's past. The same rule governs review-fix commits: the *commit message* records what the review caught; the *code comment* it touches states only the resulting truth. RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. From cc47f76cea0f83f0381a1ea71f15101402c99f46 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:33:03 +0800 Subject: [PATCH 30/87] docs: propose simplification RFCs --- docs/rfc/README.md | 19 ++++++++++++ ...06-20-assembled-assistant-messages-only.md | 31 +++++++++++++++++++ .../2026-06-20-classify-support-packages.md | 26 ++++++++++++++++ ...6-20-collapse-trace-only-session-events.md | 27 ++++++++++++++++ .../2026-06-20-discover-package-inventory.md | 26 ++++++++++++++++ .../2026-06-20-drop-acp-session-load.md | 25 +++++++++++++++ .../2026-06-20-drop-acp-terminal-meta.md | 27 ++++++++++++++++ ...2026-06-20-drop-bash-output-spill-files.md | 27 ++++++++++++++++ ...2026-06-20-drop-durable-step-boundaries.md | 27 ++++++++++++++++ .../2026-06-20-drop-unused-session-lineage.md | 26 ++++++++++++++++ ...6-20-fold-session-persistence-interface.md | 27 ++++++++++++++++ .../2026-06-20-foreground-only-bash.md | 27 ++++++++++++++++ .../2026-06-20-generic-tool-rendering.md | 31 +++++++++++++++++++ .../2026-06-20-providerless-example-base.md | 27 ++++++++++++++++ .../2026-06-20-public-agent-stop-surface.md | 26 ++++++++++++++++ ...-20-remove-agent-boundary-mirror-events.md | 30 ++++++++++++++++++ ...0-remove-redundant-snapshot-log-goldens.md | 27 ++++++++++++++++ .../2026-06-20-retire-mid-turn-steering.md | 31 +++++++++++++++++++ .../2026-06-20-single-session-acp-bridge.md | 27 ++++++++++++++++ .../2026-06-20-truncate-interrupted-turns.md | 31 +++++++++++++++++++ scripts/publint-all.ts | 1 + 21 files changed, 546 insertions(+) create mode 100644 docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md create mode 100644 docs/rfc/proposed/2026-06-20-classify-support-packages.md create mode 100644 docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md create mode 100644 docs/rfc/proposed/2026-06-20-discover-package-inventory.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-acp-session-load.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md create mode 100644 docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md create mode 100644 docs/rfc/proposed/2026-06-20-foreground-only-bash.md create mode 100644 docs/rfc/proposed/2026-06-20-generic-tool-rendering.md create mode 100644 docs/rfc/proposed/2026-06-20-providerless-example-base.md create mode 100644 docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md create mode 100644 docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md create mode 100644 docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md create mode 100644 docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md create mode 100644 docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md create mode 100644 docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 0fe36dca75..5f2e25ae31 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -32,6 +32,25 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Unify the agent id and the session id](proposed/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | +| [Retire mid-turn steering](proposed/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | +| [Stop mirroring durable boundaries as agent events](proposed/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Keep one public stop primitive](proposed/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | +| [Drop durable step boundary events](proposed/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | +| [Truncate interrupted final turns on load](proposed/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | +| [Persist assembled assistant messages, not stream chunks](proposed/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | +| [Collapse trace-only session events](proposed/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Drop unused session lineage metadata](proposed/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | +| [Make the bash tool foreground-only](proposed/2026-06-20-foreground-only-bash.md) | 2026-06-20 | +| [Drop bash full-output spill files](proposed/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | +| [Collapse tool-owned UI presentation](proposed/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | +| [Drop ACP terminal `_meta` rendering](proposed/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | +| [Return the ACP bridge to one live session per connection](proposed/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | +| [Drop ACP session/load until resume has a product shape](proposed/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | +| [Make the shared example base providerless](proposed/2026-06-20-providerless-example-base.md) | 2026-06-20 | +| [Classify product, integration, and support packages](proposed/2026-06-20-classify-support-packages.md) | 2026-06-20 | +| [Fold the persistence interface into dsh-session](proposed/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | +| [Remove redundant recorded snapshot log goldens](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Discover package inventories instead of maintaining static lists](proposed/2026-06-20-discover-package-inventory.md) | 2026-06-20 | ## Implemented diff --git a/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md new file mode 100644 index 0000000000..b7724c105c --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md @@ -0,0 +1,31 @@ +# RFC: Persist assembled assistant messages, not stream chunks + +Status: proposed + +## Problem + +The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The persistence RFC chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. + +The loop already appends an assembled `assistant/message` for each step. That is the event `deriveMessages()` uses for the next model request. In other words, the resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. + +## Proposal + +Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. + +ACP `session/load` can replay prior assistant messages as complete content blocks instead of simulating the original token stream. A loaded transcript need not reproduce every historical delta; it must show the same completed assistant content and resume with a valid provider history. + +## Acceptance criteria + +- `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed. +- Persistence docs no longer require every stream chunk to be stored verbatim. +- `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks. +- `session/load` renders completed assistant messages from `assistant/message`. +- Stored logs get much smaller and remain `seq`-contiguous without chunk holes. + +## What we give up + +The canonical user session no longer reconstructs the exact token stream of an old turn. That is acceptable for resume and load, where completed message content is the user-visible state. Tests that need exact deterministic streams should own that fixture directly instead of smuggling it through the durable session format. + +## Related + +This supersedes the chunk-persistence choice in [session persistence](../implemented/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../implemented/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events. diff --git a/docs/rfc/proposed/2026-06-20-classify-support-packages.md b/docs/rfc/proposed/2026-06-20-classify-support-packages.md new file mode 100644 index 0000000000..13f5a2b04a --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-classify-support-packages.md @@ -0,0 +1,26 @@ +# RFC: Classify product, integration, and support packages + +Status: proposed + +## Problem + +`packages/` is flat. Core product packages, provider integrations, tool implementations, example UI support, and snapshot-only replay support all sit at the same level and look equally publishable. `packages/README.md` already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more foundational than they are and forces publish/lint/doc scripts to special-case intent in prose or static lists. + +This is not just cosmetic. A package's location currently says little about whether it is core API, an integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. + +## Proposal + +Introduce an explicit package classification and move packages accordingly, for example `packages/core/`, `packages/integrations/`, `packages/tools/`, `packages/testing/`, and `packages/examples/`, or an equivalent structure decided in the implementing PR. The important part is that example/test support packages are not indistinguishable from product core. + +This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. + +## Acceptance criteria + +- Each package has an explicit classification visible from path or package metadata. +- Scripts that publish, lint publishability, or generate module graphs use the classification instead of an ad hoc static list. +- Docs explain which package classes are part of the product API. +- YAML loader paths and TypeScript path aliases are updated in one coordinated move. + +## What we give up + +The restructure churns imports, workspace globs, docs links, and package paths. That churn is acceptable pre-release if it prevents the flat layout from fossilizing a support package as a product contract. diff --git a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md new file mode 100644 index 0000000000..464eb9a154 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md @@ -0,0 +1,27 @@ +# RFC: Collapse trace-only session events + +Status: proposed + +## Problem + +The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. `usage` is already present as a model stream chunk before the loop also appends a separate `usage` event. `error` duplicates the `turn/end { kind: 'error', message, code }` reason for loop failures; ACP settlement reads the turn-end reason, ACP rendering ignores the `error` event, and `deriveMessages()` skips it. + +These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing for resume. The implemented [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md) already says post-turn operational diagnostics do not belong in the replayable session log. + +## Proposal + +Remove trace-only events from the canonical session log unless a production consumer needs them. Model usage can be derived from retained stream chunks, attached to `assistant/message`, or emitted on a separate telemetry channel. Loop errors should be represented by `turn/end.reason` for durable transcript semantics and `agent/error` or logging for operational diagnostics. Do not keep a parallel `error` event that consumers must reconcile with the final turn reason. + +If analytics become real, add a projection helper or a dedicated telemetry store with its own retention policy. The user conversation log should contain what is needed to render, resume, and audit the interaction, not every metric-shaped detail the loop happened to observe. + +## Acceptance criteria + +- `SessionEventMap` drops `usage` and `error`, or folds their fields into nearby load-bearing events. +- The loop no longer appends a separate `usage` event for a usage chunk. +- The loop records durable failures only as `turn/end { kind: 'error' }` and reports live diagnostics through `agent/error`. +- ACP snapshots and persistence tests stop asserting trace-only lines. +- Documentation explains where token usage and operational errors are observed if they remain available. + +## What we give up + +A consumer can no longer filter the canonical log for `usage` or step-level `error` events. That is a real loss for future analytics and debugging, but there is no current production analytics consumer. Keeping a telemetry-shaped event in the replay log because it might matter later repeats the dead-summary pattern from [drop the mutable session summary](../implemented/2026-06-19-drop-mutable-session-summary.md). diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md new file mode 100644 index 0000000000..cafb70a948 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md @@ -0,0 +1,26 @@ +# RFC: Discover package inventories instead of maintaining static lists + +Status: proposed + +## Problem + +Package and gate inventories are repeated by hand. `scripts/publint-all.ts` has a static list of publishable packages. The package cookbook tells authors to update several files. The package README carries a hand-written dependency graph. CI and development docs can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. + +Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data that already exists in `package.json`, workspace globs, or package metadata. + +## Proposal + +Make package/gate inventories discoverable. Publishability should come from package metadata or classification, not from a static array in a script. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. + +This pairs well with [classifying support packages](2026-06-20-classify-support-packages.md), because discovery needs to know which packages are product-publishable, support-only, private, or examples. + +## Acceptance criteria + +- `publint-all` discovers publishable packages from manifests or a single classification source. +- Adding a package does not require editing a static package list for every gate. +- Docs describe the source of truth rather than repeating generated inventories. +- CI invokes the aggregate commands and lets those commands own their sub-gate lists. + +## What we give up + +Discovery scripts can become too clever. The implementation should stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud. The payoff is removing manual inventory drift, not inventing a build system. diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md b/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md new file mode 100644 index 0000000000..f8a0853524 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md @@ -0,0 +1,25 @@ +# RFC: Drop ACP session/load until resume has a product shape + +Status: proposed + +## Problem + +ACP advertises `loadSession: true` and implements `session/load` by injecting persistence into the bridge, validating cwd against stored metadata, reconstructing an agent from the persisted log, and replaying prior transcript updates to the client. That path has its own race handling, loading-id guard, replay presenter logic, and tests. It also depends on the canonical log retaining enough UI data to reconstruct old chunks and tool presentations. + +Durable persistence remains foundational, but editor-visible resume is not yet a designed product flow. There is no session picker, no title/preview metadata, and no clear UX for failed or partial loads. The bridge is paying complexity for a feature that is mostly exercised by tests and documentation. + +## Proposal + +For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: false` or omits the capability, and `session/load` is unsupported. Persistence remains available to the agent loop and tests; resume can still exist as a lower-level factory if another consumer needs it. The editor bridge should reintroduce `session/load` alongside a real session-selection UX and a stable load transcript contract. + +## Acceptance criteria + +- ACP no longer injects `sessionPersistence` solely for `session/load`. +- `initialize` does not advertise load support. +- The `session/load` handler, loading-id tracking, cwd preflight for loaded sessions, and load replay tests are removed. +- Snapshot fixtures no longer rely on load replay presentation. +- ACP docs describe fresh-session support only. + +## What we give up + +An editor cannot reopen a prior persisted session through ACP. That is a real product feature, but the current implementation is ahead of the UX and ties the bridge to token-level log replay. Keeping persistence while dropping editor load narrows the bridge to the workflow it can currently present cleanly. diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md new file mode 100644 index 0000000000..897f23a2e8 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md @@ -0,0 +1,27 @@ +# RFC: Drop ACP terminal `_meta` rendering + +Status: proposed + +## Problem + +The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented RFC deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. + +The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway. + +## Proposal + +Ignore `clientCapabilities._meta.terminal_output` and render bash results through the plain ACP content path. Keep execution agent-side through `dsh-bash`; only the display-specific terminal metadata is removed. A terminal card can return later if ACP standardizes agent-executed terminals or if the product decides Zed-specific display is worth the maintenance cost. + +This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-20-generic-tool-rendering.md): it keeps generic `presentCall`/`presentResult` if those survive, but removes the terminal sub-shape and `_meta` mapping. + +## Acceptance criteria + +- ACP no longer reads or stores `_meta.terminal_output` capability state. +- `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`. +- `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup. +- Bash result presentation no longer parses exit status for terminal pills. +- The implemented terminal-rendering RFC is superseded or moved to rejected with this proposal linked. + +## What we give up + +Zed users lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They still see the command and output as plain content. That is a reasonable simplification while the ACP bridge is still unreleased and the `_meta` keys are a convention rather than a standard. diff --git a/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md new file mode 100644 index 0000000000..2df48cd3cc --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md @@ -0,0 +1,27 @@ +# RFC: Drop bash full-output spill files + +Status: proposed + +## Problem + +`dsh-bash-local` keeps bounded in-memory output and spills large stdout/stderr streams into private temp files. That requires a private directory, random owner-only file creation, close-failure handling, byte-offset incremental reads, lossy read reporting, path rendering in model-facing text, and cleanup discipline. The tool then tells the model to read a local spill path when output was truncated. + +This solves a real problem, but in a narrow and leaky way. A spill path is a process-local filesystem artifact exposed to model output, not a durable harness artifact with scoped access, retention, or UI affordances. It also complicates background-task reads because a lossy incremental read has to point at one or two spill files. + +## Proposal + +Keep tail truncation, drop full-output spill files. A bash result contains the bounded tail plus a clear truncation marker; no path is emitted. If users need full-output recovery, add a generic artifact/blob service with explicit ownership, cleanup, and UI rendering, then let bash attach large outputs to that service. + +This proposal can land independently of [foreground-only bash](2026-06-20-foreground-only-bash.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path. + +## Acceptance criteria + +- `CollectedOutput` no longer carries spill paths. +- `OutputCollector` keeps bounded buffers only and deletes the temp-file machinery. +- `renderResult()` reports truncation without a filesystem path. +- Tests cover tail truncation and no longer assert full-output file contents. +- Security docs stop treating private spill files as a model-visible interface. + +## What we give up + +A model or user cannot recover the omitted prefix of a huge command output from a temp file. That is acceptable until there is a real artifact service. The current spill path is too much bespoke machinery for a feature whose lifecycle and permissions are not designed. diff --git a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md new file mode 100644 index 0000000000..356fb20c31 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md @@ -0,0 +1,27 @@ +# RFC: Drop durable step boundary events + +Status: proposed + +## Problem + +The session log stores `step/start` and `step/end` events even though every step-scoped event already carries `{ turn, step }`: assistant chunks, assistant messages, tool calls, tool results, usage, and errors. `deriveMessages()` ignores step boundaries, ACP ignores them for UI, and the main consumers are invariants, tests, snapshot goldens, and crash repair. + +The boundary events make the log more ceremonial than informative. The loop tracks open steps solely to close them, repair synthesizes `step/end` when a crash leaves a step open, invariants track a second nesting stack inside the turn, and snapshots carry lines that do not affect replayed message history. A model request that crashes before producing any step-scoped event is the only information represented by a bare `step/start`, and that case has no useful resumable content. + +## Proposal + +Make the turn the only durable boundary. Remove `step/start` and `step/end` from `SessionEventMap`; keep the numeric `step` field on events that need grouping. The loop increments the step counter and records step-scoped events with that number, but it no longer appends open/close boundary events. Consumers infer step groups from contiguous events sharing `(turn, step)`. + +The invariants plugin should enforce that step-scoped events have valid positive step numbers within an open turn, not that separate boundary records surround them. Crash repair should not synthesize `step/end`; if [interrupted turns are truncated](2026-06-20-truncate-interrupted-turns.md), the repair path disappears entirely. + +## Acceptance criteria + +- `SessionEventMap` no longer includes `step/start` or `step/end`. +- The loop has no `closeStep()` finalization path. +- ACP snapshots and persistence contract fixtures stop expecting step-boundary lines. +- `deriveMessages()` and replay derive the same message history from step-scoped events. +- The event taxonomy docs describe turns as the durable boundary and steps as a field on step-scoped records. + +## What we give up + +The log no longer records "a model request started but produced no event before the process died" as a durable fact. That is acceptable: there is no assistant content, tool call, usage, or error to replay from that empty request. A live UI can still show an in-progress step from a transient event if it needs one; the durable log should not store an empty bracket. diff --git a/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md new file mode 100644 index 0000000000..35fcb3fc63 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md @@ -0,0 +1,26 @@ +# RFC: Drop unused session lineage metadata + +Status: proposed + +## Problem + +`SessionHeader.parentSession` records the session a new session was forked from. It is defined in `dsh-session`, preserved by persistence backends, copied through resume, documented as lineage metadata, and covered by round-trip tests. The repo has no production fork UI or sub-agent flow that reads it. The planned sub-agent/fork seam is still a TODO, so the field is currently stored future shape. + +The cost is small per file but broad across the format: every backend schema and metadata serializer preserves a value that no feature uses. Because the header is an on-disk contract, even a placeholder field becomes something future refactors must either maintain, migrate, or deliberately break. + +## Proposal + +Remove `parentSession` from `SessionHeader` until a real fork/resume feature needs lineage. Forking can still seed a new session with prior events if such an API exists, but the durable parent pointer should be introduced alongside the feature that reads it and the UX that explains it. + +If lineage returns, decide then whether it belongs in the immutable header, a session graph index, or a first-class event. The current field should not pre-commit that design. + +## Acceptance criteria + +- `SessionHeader` contains version, id, createdAt, and optional cwd only. +- JSONL and SQLite metadata schemas stop storing parent-session ids. +- Resume and list APIs no longer round-trip `parentSession`. +- Docs and tests remove fork-lineage claims that are not backed by a production consumer. + +## What we give up + +The codebase loses a ready-made lineage hook for future fork/sub-agent UX. That is intentional. The field is easy to reintroduce when the feature exists, and the unreleased stance lets the format change without migrations. diff --git a/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md new file mode 100644 index 0000000000..d2e8466d07 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md @@ -0,0 +1,27 @@ +# RFC: Fold the persistence interface into dsh-session + +Status: proposed + +## Problem + +`dsh-session-persistence` is an interface package whose main concepts are already owned by `dsh-session`: `SessionHeader`, `SessionEvent`, `SessionId`, `session/event`, and `session/flush`. The package adds the abstract `SessionPersistence` service, the shared write coordinator, and contract helpers. Backend packages depend on it, and `agent-loop` has to optionally find a sibling service for resume. + +The capability-seam split made sense when persistence was a new swappable backend design. After the mutable summary was removed, the interface package mostly wraps the session log's own storage concern. Keeping it separate may be more ceremony than clarity. + +## Proposal + +Move the abstract `SessionPersistence` service, the coordinator, and persistence contract helpers into `dsh-session`. Keep JSONL and SQLite as separate backend packages that register the session-owned service. This preserves backend swappability while deleting one support package and one cross-package seam. + +The implementing PR should update the [capability seams](../implemented/2026-06-13-capability-seams.md) guidance with the exception: persistence is not like bash or LLM because its vocabulary and lifecycle events are already the session package's core domain. + +## Acceptance criteria + +- `@deepseek-ai/dsh-session-persistence` is removed as a package. +- `dsh-session` exports the persistence service type, coordinator, and contract helpers. +- JSONL and SQLite backend packages depend on `dsh-session` directly. +- `agent-loop` resume uses the session-owned service key. +- Persistence RFCs and package docs explain why backend implementations remain separate. + +## What we give up + +`dsh-session` becomes heavier: it owns both the in-memory log and the persistence interface. That is the trade. If third-party persistence backends were already a public ecosystem, the separate interface package would be a cleaner SDK boundary; pre-release, the extra package looks like abstraction before there is an external consumer. diff --git a/docs/rfc/proposed/2026-06-20-foreground-only-bash.md b/docs/rfc/proposed/2026-06-20-foreground-only-bash.md new file mode 100644 index 0000000000..42401151a1 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-foreground-only-bash.md @@ -0,0 +1,27 @@ +# RFC: Make the bash tool foreground-only + +Status: proposed + +## Problem + +The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. Recent work added owner-token isolation because global predictable task ids become a cross-session read/kill hazard. + +The cookbook already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. + +## Proposal + +Temporarily collapse `bash` to foreground-only execution. Remove `run_in_background`, `bash_output`, `bash_kill`, background task ownership, incremental task reads, completion injection, and task-listener APIs from the public bash executor seam. Long commands can still run with an explicit timeout; a command that needs to outlive a model step is not supported until a generic task service exists. + +If long-running tasks return later, implement them once as a capability-agnostic task layer that owns ids, authorization, polling, cancellation, completion notifications, and any UI affordances. Bash can then opt into that layer like any other tool. + +## Acceptance criteria + +- `@deepseek-ai/dsh-tool-bash` registers only the `bash` tool. +- `BashExecutor` exposes `resolve()` and foreground `run()` only. +- `@deepseek-ai/dsh-bash-local` no longer tracks background task maps, owner tokens, task listeners, or incremental output cursors. +- ACP and snapshot fixtures no longer mention `bash_output` or `bash_kill`. +- The cookbook either removes the background example or redirects long-running work to the future generic task RFC. + +## What we give up + +The model loses the ability to start a server or long-running command, continue other work, and poll later. That is a real capability regression, but the current design makes one tool carry infrastructure that belongs above all tools. Foreground-only bash is smaller, safer, and easier to sandbox while the generic long-running-tool design is still absent. diff --git a/docs/rfc/proposed/2026-06-20-generic-tool-rendering.md b/docs/rfc/proposed/2026-06-20-generic-tool-rendering.md new file mode 100644 index 0000000000..e6bde8f434 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-generic-tool-rendering.md @@ -0,0 +1,31 @@ +# RFC: Collapse tool-owned UI presentation + +Status: proposed + +## Problem + +Tools can define `presentCall()` and `presentResult()` callbacks that return `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flags the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal grew incrementally into a bag of optional fields. ACP then maintains pending call state to pair a result with the original args, creates replay-only presenters on `session/load`, and maps terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parses exit status back out of rendered text because the pure replay-safe presenter no longer has the structured `BashRunResult`. + +The real first-party use is bash presentation for ACP. That is too little evidence to freeze a cross-package UI presentation API. + +## Proposal + +Remove tool-owned UI presentation callbacks for now. The canonical tool events already carry the tool name, raw argument string, result content, and error state. UIs render a generic tool card from those fields. Tool-specific rich rendering can return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary. + +As a smaller alternative, replace the current optional-field bag with one explicit union in a single PR; but if the goal is simplification, the stronger move is to delete the callbacks and keep the generic path. + +## Acceptance criteria + +- `ToolDefinition` drops `presentCall` and `presentResult`. +- `ToolCallPresentation`, `ToolResultPresentation`, `ToolTerminal`, and `ToolCallKind` disappear unless a minimal generic UI type still needs one. +- ACP no longer keeps presenter pending state or calls tool callbacks during live streaming/load replay. +- `dsh-tool-bash` no longer parses rendered text to recover exit status for a UI pill. +- Snapshot goldens show generic tool cards and text results. + +## What we give up + +Bash loses its custom terminal-looking card and model-written description placement. The fallback remains reasonable: the command appears as tool input, and the output appears as text. Rich rendering should be designed when the product has enough UI/tool variety to justify a stable presentation contract. + +## Related + +This is the broad version of [dropping ACP terminal metadata](2026-06-20-drop-acp-terminal-meta.md). If this RFC is accepted, that narrower RFC becomes unnecessary. diff --git a/docs/rfc/proposed/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/2026-06-20-providerless-example-base.md new file mode 100644 index 0000000000..cefcccea08 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-providerless-example-base.md @@ -0,0 +1,27 @@ +# RFC: Make the shared example base providerless + +Status: proposed + +## Problem + +The examples have two shared base files: `examples/base-core.yml` is providerless, while `examples/base.yml` includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. + +The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. + +## Proposal + +Rename the providerless core to `examples/base.yml` and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete `base-core.yml`. + +The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. + +## Acceptance criteria + +- `examples/base.yml` is providerless. +- `examples/base-core.yml` is deleted. +- Real demo configs explicitly add the DeepSeek adapter. +- Snapshot replay config includes the same providerless base and its replay adapter. +- README and RFC references stop explaining "base = base-core plus adapter". + +## What we give up + +Real demos lose one layer of convenience: each must opt into the adapter. That is the right default for examples, because adapter choice is the variable part and providerless wiring is the shared product core. diff --git a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md new file mode 100644 index 0000000000..dfc2da2677 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md @@ -0,0 +1,26 @@ +# RFC: Keep one public stop primitive + +Status: proposed + +## Problem + +The public `Agent` handle exposes three ways to reason about stopping work: `abort(reason?)`, `cancel(reason?)`, and `whenIdle()`. `abort()` kills only the in-flight step and leaves queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race; `whenIdle()` exposes the loop's private quiescence waiter to any consumer. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs bare `abort()` or `whenIdle()`. + +The extra surface area makes the loop carry public semantics that are mostly teardown internals. `whenIdle()` needs waiter state, special disposed-agent behavior, and a loop-exit promise so it resolves after quiescence rather than merely after a status flip. `abort()` has to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. + +## Proposal + +Keep `cancel()` as the only public stop primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation can keep private abort controllers and quiescence promises, but they are not part of the plugin-facing `Agent` contract. + +Delete public `abort()` and `whenIdle()`, the tests that exercise them as standalone API, and the docs that describe step-only abort as an embedding feature. The disposer remains async and still waits for the loop to stop; that guarantee moves entirely onto `AgentHandle.dispose()`. + +## Acceptance criteria + +- `Agent` exposes `send()`, `inject()`, `cancel()`, status, options, session, and identity, with no public `abort()` or `whenIdle()`. +- ACP cancellation continues to call `cancel()`. +- Agent teardown continues to await quiescence through handle disposal. +- Tests cover cancellation and disposal as the two supported stop paths. + +## What we give up + +A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps private loop mechanics public. diff --git a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md new file mode 100644 index 0000000000..7b2aa2d748 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md @@ -0,0 +1,30 @@ +# RFC: Stop mirroring durable boundaries as agent events + +Status: proposed + +## Problem + +The loop records the canonical transcript in `SessionEvent` and also emits a parallel set of live `agent/*` mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, `agent/queued`, and `agent/steering`. The mirrors make consumers choose between two sources of truth. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI is the only production consumer that still renders primarily from the mirror stream. + +This duplication is not free. Every lifecycle change has to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also make failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. + +## Proposal + +Make `session/event` the live transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. Keep agent lifecycle/control events that are not transcript data: `agent/created`, `agent/disposed`, `agent/status`, and `agent/error`. Keep any live-only token stream only if the canonical log separately stops storing chunks; otherwise `assistant/chunk` session events cover that too. + +Remove the duplicate durable-boundary mirrors from the agent event taxonomy. If a UI wants an agent handle from a session event, it can keep a small map from session id to agent built from `agent/created`/`agent/disposed`, or the registry can offer an explicit lookup. The canonical record remains the event-sourced session log. + +## Acceptance criteria + +- ACP and stdio render transcript content from `session/event`. +- `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, and `agent/steering` are removed or reduced to private implementation details. +- Tests assert the persisted event stream, not a second mirror stream, for turn and step ordering. +- Documentation presents `SessionEvent` as both the durable source and the live transcript feed. + +## What we give up + +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: transcript consumers should not depend on a second event feed that can drift from the durable log. + +## Related + +This is compatible with [assembled assistant messages only](2026-06-20-assembled-assistant-messages-only.md), but the exact fate of `agent/stream-chunk` depends on that decision. If chunks leave the canonical log, `agent/stream-chunk` can remain as a deliberately live-only UI signal while the other mirror events disappear. diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md new file mode 100644 index 0000000000..e4dc94e2be --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -0,0 +1,27 @@ +# RFC: Remove redundant recorded snapshot log goldens + +Status: proposed + +## Problem + +Recorded ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. + +The duplicate file can help review by showing "expected persisted log" separately from "model replay input", but for recorded scenarios those are intentionally the same artifact. Keeping both means a re-record churns two files with the same semantic content. + +## Proposal + +For recorded scenarios, compare the replay run's normalized session log directly against normalized `session.jsonl`. Keep explicit `session.golden.jsonl` only for authored scenarios where `replay.override.json` drives behavior that is not derivable from the fixture, or where the expected persisted log intentionally differs from the replay script. + +Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. + +## Acceptance criteria + +- Recorded scenarios stop committing `session.golden.jsonl`. +- The snapshot test derives the expected session log from `session.jsonl` for `recorded: true` scenarios. +- Authored sidecar scenarios keep explicit session goldens when needed. +- Orphan-fixture guards understand which files are required by scenario kind. +- The snapshot-test RFC is updated to describe the reduced fixture set. + +## What we give up + +Reviewers lose one redundant artifact that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to the recorded fixture preserves the loop/persistence regression check without duplicating files. diff --git a/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md new file mode 100644 index 0000000000..6b699eb771 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md @@ -0,0 +1,31 @@ +# RFC: Retire mid-turn steering + +Status: proposed + +## Problem + +The agent exposes two user-message paths that look close but have different lifecycle semantics: `send()` queues a normal user turn, while `steer()` injects a message between steps of the currently running turn and falls back to `send()` when idle. That distinction leaks through the whole stack: `Agent.steer()` is public API, the session log has a durable `steering/message` event, the agent event taxonomy has `agent/steering`, the loop maintains a steering FIFO beside the queued-message FIFO, cancellation clears both queues, and `deriveMessages()` has to render steering as a tagged synthetic user message rather than a normal prompt. + +The continuation seam amplifies the cost. `agent/turn-continuation` defaults to `hadToolCalls || steeringInjected`, so a same-turn steering message can force the loop to call the model again even if the model did not ask for tools. The comments name future `/goal`, `/loop`, and budget-guard uses, but the current repo has no production listener. The only production UI that mentions steering is the stdio demo; ACP already sends prompts through the ordinary queue while a turn is running. + +## Proposal + +Delete mid-turn user steering for now. `Agent.send()` becomes the single public way to submit user content; when the agent is running, the content waits for the next turn. The loop continues within a turn only for tool calls, not because a user typed while a step was running. A caller that wants to interrupt the current turn uses `cancel()` and then `send()`. + +Remove `Agent.steer()`, the steering FIFO, `steering/message`, `agent/steering`, steering-derived continuation, and the cancellation logic that distinguishes queued messages from steering messages. Revisit `agent/turn-continuation` at the same time: if there is still no production listener, remove the waterfall too and let the loop continue only on the closed set of reasons it owns. If a real budget or goal plugin later needs forced continuation, it should reintroduce a narrower seam with that plugin as the concrete consumer. + +## Acceptance criteria + +- `Agent` exposes one user-message entry point, `send()`. +- The durable session event vocabulary no longer contains `steering/message`. +- `deriveMessages()` renders normal user messages and context injections, with no steering tag path. +- The loop has one queued-message FIFO and no same-turn user-message continuation path. +- The stdio UI and docs describe input while running as queued next-turn input. + +## What we give up + +A user cannot add same-turn steering content while a model is between tool steps. That behavior is useful in theory for "while you are already working, also consider X", but it is not the behavior ACP exposes today and it makes the turn boundary much harder to reason about. The simpler behavior is reasonable: user input becomes the next prompt, and cancellation remains the explicit tool for replacing in-flight work. + +## Related + +This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering leaves tool calls as the only reason a turn contains multiple model steps. diff --git a/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md b/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md new file mode 100644 index 0000000000..6c3c88a2cc --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md @@ -0,0 +1,27 @@ +# RFC: Return the ACP bridge to one live session per connection + +Status: proposed + +## Problem + +The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. A separate proposed RFC still tracks the unfinished permission-ownership piece. + +The product has not yet proven it needs concurrent editor conversations over one harness process. The snapshot replay tier also avoids concurrent model streams because its replay entries are positional; concurrency would require keying replay by request instead of by stream order. + +## Proposal + +Scope ACP back to one live session per connection. `session/new` or `session/load` creates the only session record; a second live session request is rejected until the existing session is disposed or the connection closes. If editors need multiple chat tabs, they can launch multiple agent subprocesses until the bridge has a concrete multi-session UX and permission model. + +Remove the multi-session maps and demux where a single `SessionRecord | undefined` is enough. The bridge can still keep the agent/session lifecycle seams that make disposal correct; the simplification is only about multiplexing more than one active session through the same transport. + +## Acceptance criteria + +- ACP has one active session record per connection. +- `session/new` and `session/load` reject while that record exists. +- Event handlers no longer demux across a `Map`. +- Multi-session tests are removed or moved to a rejected/superseded proposal. +- The existing [multi-session ACP proposal](2026-06-14-acp-multi-session.md) is updated to link this RFC if rejected. + +## What we give up + +An ACP client cannot host several concurrent conversations on one server process. That is a meaningful capability cut. The simpler model is still reasonable for an unreleased harness: one editor conversation maps to one agent process, and cross-session permission/background-task isolation stops being a live correctness burden. diff --git a/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md new file mode 100644 index 0000000000..a0a16031c5 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md @@ -0,0 +1,31 @@ +# RFC: Truncate interrupted final turns on load + +Status: proposed + +## Problem + +The current persistence contract preserves a final turn that was durably written but never closed. On load, `interruptedTurnClosers()` scans the tail, synthesizes error `tool/result` events for unanswered tool calls, appends a `step/end` when a step is open, appends `turn/end { kind: 'interrupted' }`, and asks the backend to durably commit that repair. The coordinator, JSONL backend, SQLite backend, session event vocabulary, invariants, docs, and tests all model this synthetic close path. + +This is a lot of machinery to preserve partial work from the last crashed turn. It also invents events that never happened. A synthetic tool result is useful because it makes provider history valid, but it also means the resumed log contains model-visible text that no tool produced. The current design optimizes for maximum tail preservation before there is a released product or a real resume UX that proves partial-turn recovery matters. + +## Proposal + +On load, keep only the last completed turn. A backend still tolerates and truncates a torn final record, but if the parsed durable prefix ends after an open `turn/start`, the canonical repair is to drop every event after the previous `turn/end`. No synthetic `tool/result`, no synthetic `step/end`, no `turn/end { interrupted }`, and no `interrupted` turn-end reason. + +This makes the persisted turn boundary simple: a completed `turn/end` is the checkpoint. Anything after the last checkpoint is crash tail. The next prompt resumes from the last known-valid provider transcript, not from a partially reconstructed final turn. + +## Acceptance criteria + +- `TurnEndReasonMap` drops the `interrupted` variant. +- `interruptedTurnClosers()` and its tests disappear. +- The persistence coordinator's repair hook truncates backend-specific torn/open tail state without appending closers. +- Persistence docs say load returns the last completed turn, plus no partial final turn. +- Snapshot and contract tests update together with the behavior they pin. + +## What we give up + +A crash can lose real work from the final turn: assistant text, tool calls, and tool output appended after the previous `turn/end`. That is the deliberate simplification. The product is unreleased, the final-turn recovery semantics are not user-proven, and a clean completed-turn checkpoint is much easier to explain, test, and implement. A future "recover partial crashed work" feature should be designed as an explicit user-facing recovery view, not as synthetic events silently inserted into the canonical transcript. + +## Related + +This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 0b8f7870c7..376f02c9df 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' // publint every publishable package (vendor/ is private upstream code and // examples/ are not packages; both are out of scope). +// TODO(package-inventory): derive this from package metadata/classification. const packages = [ 'packages/llm', 'packages/session', From c3278e96605abd17017d669b0ac1f087733baad9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:57:22 +0800 Subject: [PATCH 31/87] docs: add ACP feature support checklist Inventory the ACP v1 surface (stable schema 1.14.0 plus the unstable features the claude-agent-acp and codex-acp reference adapters ship) and mark where the dsh-acp bridge stands on each: agent methods, client methods, capabilities, session/update variants, tool-call rendering, content blocks, and a ranked gap summary. --- docs/acp-feature-support.md | 163 ++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/acp-feature-support.md diff --git a/docs/acp-feature-support.md b/docs/acp-feature-support.md new file mode 100644 index 0000000000..a0e1dd26cd --- /dev/null +++ b/docs/acp-feature-support.md @@ -0,0 +1,163 @@ +# ACP feature support checklist + +A structured inventory of [Agent Client Protocol](https://agentclientprotocol.com) (ACP) features and where the harness's ACP bridge ([`@deepseek-ai/dsh-acp`](../packages/acp/README.md)) stands on each. The bridge exposes the harness agent as an ACP **server** (the agent side of an editor↔agent connection), so "supported" below means *the bridge implements the agent's half* — answering an agent method, advertising a capability, or calling a client method. + +## Scope + +This tracks the **stable** ACP v1 surface (schema `1.14.0`, `schema/v1/schema.json`) PLUS the **unstable/draft** features that the two reference adapters — [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) (Claude Code) and [`codex-acp`](https://github.com/zed-industries/codex-acp) (OpenAI Codex) — actually ship. A purely-unstable feature that neither reference adapter uses is omitted (see [Out of scope](#out-of-scope)). + +Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. The **Stable** column marks whether the feature is in the released v1 schema (S) or only the unstable schema (U). The **Claude** / **Codex** columns record whether each reference adapter ships it, as a maturity signal. + +## At a glance + +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), the client **filesystem** and **terminal** method families, **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship. See [Gap summary](#gap-summary). + +## 1. Agent methods (client → agent) + +| Method | Stable | Bridge | Claude | Codex | Notes | +|---|---|---|---|---|---| +| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession` + baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. | +| `authenticate` | S | ⚠️ | ✅ | ✅ | No-op stub; the bridge advertises no `authMethods`, so there is nothing to authenticate. | +| `logout` | S | ❌ | ✅ | ✅ | Gated by `agentCapabilities.auth.logout`; not advertised. | +| `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. | +| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | +| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | +| `session/close` | S | ⚠️ | ✅ | ✅ | No explicit `session/close` handler; the bridge tears a session down on client disconnect / disposal, not on demand per session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | +| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | +| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes not modeled (see [§6 Modes](#6-session-modes--config-options--models)). | +| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. | +| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. | +| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. | +| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. | +| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. | + +## 2. Client methods the agent CALLS (agent → client) + +These are capabilities the bridge would *drive* on the editor. The harness runs tools in-process (its own `dsh-bash` executor, direct file I/O), so it does not yet delegate to the editor for any of these. + +| Method | Stable | Bridge | Claude | Codex | Notes | +|---|---|---|---|---|---| +| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). | +| `session/request_permission` | S | ❌ | ✅ | ✅ | **The biggest gap.** Tools run with the executor's full authority; no user authorization round-trip. The `agent→sessionId` reverse map is already in place to route a future permission request. Tracked `TODO(rfc010-permission-gate)`. | +| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. | +| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. | +| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). | +| `terminal/output` | S | ❌ | ❌ | ❌ | As above. | +| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. | +| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. | +| `terminal/release` | S | ❌ | ❌ | ❌ | As above. | +| `elicitation/create` · `elicitation/complete` | U | ❌ | ✅ | ✅ | Structured user-input forms; both adapters use the `unstable_*` elicitation methods (mostly to surface MCP server elicitations). | + +## 3. Capabilities + +### 3a. `agentCapabilities` (advertised by the bridge) + +| Capability | Stable | Bridge | Claude | Codex | Notes | +|---|---|---|---|---|---| +| `loadSession` | S | ✅ | ✅ | ✅ | Advertised `true`; backs `session/load`. | +| `promptCapabilities.image` | S | ❌ | ✅ | ✅ | Bridge advertises `image: false`; image prompt blocks are rejected. | +| `promptCapabilities.audio` | S | ❌ | ❌ | ❌ | `audio: false`; neither adapter accepts audio either. | +| `promptCapabilities.embeddedContext` | S | ❌ | ✅ | ✅ | `embeddedContext: false`; embedded `resource` blocks rejected. | +| `mcpCapabilities.{http,sse}` | S | ❌ | ✅ | ⚠️ | No MCP passthrough; `mcpServers` is rejected. Claude advertises http+sse, Codex http only. | +| `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. | +| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. | + +### 3b. `clientCapabilities` (consumed by the bridge) + +| Capability | Stable | Bridge | Notes | +|---|---|---|---| +| `fs.{readTextFile,writeTextFile}` | S | ❌ | Not consulted (the bridge never calls `fs/*`). | +| `terminal` | S | ❌ | Not consulted; the bridge keys terminal rendering off the Zed `_meta.terminal_output` cap instead. | +| `_meta.terminal_output` (Zed) | S (`_meta`) | ✅ | Snapshotted per session at create/load; gates terminal-card rendering. | + +## 4. `session/update` variants + +| `sessionUpdate` | Stable | Bridge | Claude | Codex | Notes | +|---|---|---|---|---|---| +| `agent_message_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` text-delta. | +| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. | +| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. | +| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | +| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. | +| `plan` | S | ❌ | ✅ | ⚠️ | No agent plan emitted. Claude emits real plan entries; Codex renders plan as plain message text. | +| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | +| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | +| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. | +| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness HAS usage events internally). | +| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. | + +## 5. Tool-call rendering + +Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). + +| Feature | Stable | Bridge | Claude | Codex | Notes | +|---|---|---|---|---|---| +| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | +| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | +| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | +| `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). | +| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. | +| `locations` (follow-along) | S | ❌ | ✅ | ✅ | No file-location hints emitted. | +| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. | +| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. | + +### Terminal rendering + +⚠️ Implemented via the **Zed `_meta` convention** (`terminal_info` / `terminal_output` / `terminal_exit`), gated on the client advertising `_meta.terminal_output` — NOT the spec's `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox / env-scrub / ownership / cwd). Both reference adapters take the same `_meta` approach. Live incremental streaming (`terminal_output_delta`, which Codex negotiates) is a follow-up — the bridge currently sends the full captured output once on the result. + +## 6. Session modes / config options / models + +❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it. + +## 7. Content blocks + +| Block | Stable | In prompts | In updates | Notes | +|---|---|---|---|---| +| `text` | S | ✅ | ✅ | Baseline. | +| `resource_link` | S | ✅ | ⚠️ | Accepted in prompts and rendered into text (`acpPromptToText`); not emitted as a structured update block. | +| `image` | S | ❌ | ❌ | Rejected in prompts (`promptCapabilities.image: false`). | +| `audio` | S | ❌ | ❌ | Rejected. | +| `resource` (embedded) | S | ❌ | ❌ | Rejected (`embeddedContext: false`). | + +The bridge rejects unsupported prompt blocks rather than silently dropping them (`promptHasUnsupportedContent`), per the "explicit over implicit" convention. + +## 8. Cross-cutting + +| Feature | Stable | Bridge | Notes | +|---|---|---|---| +| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. | +| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](rfc/proposed/2026-06-14-acp-multi-session.md). | +| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. | +| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. | +| Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. | +| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. | + +## Gap summary + +Ranked by how commonly the reference adapters ship them and how much UX they unlock: + +1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired. Foundational, and a prerequisite for modes. +2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. +3. **Modes / config options / model selection** — coupled to the permission gate. +4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. +5. **Slash commands** (`available_commands_update`). +6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). +7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). +8. **Diff + location tool rendering** — `diff` content and `locations` for edit tools. +9. **Usage reporting** (`usage_update`) — the harness already has the internal usage events. +10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. + +## Out of scope + +Unstable/draft ACP features that **neither** reference adapter ships are not tracked above: `providers/*` (LLM provider selection), `mcp/connect`·`mcp/message`·`mcp/disconnect` (client-side MCP passthrough), `nes/*` (Next Edit Suggestion), `document/did*` (LSP-style document sync), the v2 plan model (`plan_update` / `plan_removed`), boolean config options, `$/cancel_request`, and the draft Streamable-HTTP transport. They can be added if a target editor adopts them. + +## Sources + +- Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo. +- Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp). +- Bridge: [`packages/acp/README.md`](../packages/acp/README.md), [`packages/acp/src/index.ts`](../packages/acp/src/index.ts), and the ACP RFCs under [`docs/rfc/`](rfc/README.md). + From ea3f138ae971ba2ca5d64127d4ab3176dbf8d6ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:26:23 +0800 Subject: [PATCH 32/87] docs: address simplification RFC review --- docs/rfc/proposed/2026-06-14-acp-multi-session.md | 2 ++ .../2026-06-20-assembled-assistant-messages-only.md | 11 ++++++----- .../proposed/2026-06-20-classify-support-packages.md | 2 +- .../2026-06-20-collapse-trace-only-session-events.md | 1 + .../proposed/2026-06-20-discover-package-inventory.md | 6 +++--- docs/rfc/proposed/2026-06-20-drop-acp-session-load.md | 2 +- .../rfc/proposed/2026-06-20-drop-acp-terminal-meta.md | 4 ++-- .../2026-06-20-drop-bash-output-spill-files.md | 2 +- .../2026-06-20-drop-durable-step-boundaries.md | 3 ++- .../2026-06-20-drop-unused-session-lineage.md | 1 + .../2026-06-20-fold-session-persistence-interface.md | 2 +- docs/rfc/proposed/2026-06-20-foreground-only-bash.md | 8 ++++---- .../proposed/2026-06-20-providerless-example-base.md | 10 +++++----- .../proposed/2026-06-20-public-agent-stop-surface.md | 6 +++++- .../2026-06-20-remove-agent-boundary-mirror-events.md | 5 +++-- ...026-06-20-remove-redundant-snapshot-log-goldens.md | 2 +- .../proposed/2026-06-20-retire-mid-turn-steering.md | 8 +++++--- .../proposed/2026-06-20-single-session-acp-bridge.md | 6 +++--- .../proposed/2026-06-20-truncate-interrupted-turns.md | 3 ++- scripts/publint-all.ts | 2 +- 20 files changed, 50 insertions(+), 36 deletions(-) diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 2eadf3eb63..33d4a7c177 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -5,6 +5,8 @@ Status: proposed > **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. +> **Competing simplification:** [Return the ACP bridge to one live session per connection](2026-06-20-single-session-acp-bridge.md) proposes reversing the multiplexing scope until the product has a concrete multi-session UX and permission model. While both RFCs remain proposed, this one represents the "finish multiplexing" path and the newer RFC represents the "remove multiplexing" path. + ## Problem [ACP support](2026-06-14-acp-agent-client-protocol.md) ships with a single active session per connection: a second `session/new` is rejected. Editors expect to run several conversations over one agent subprocess — a user opens multiple threads, or a client pre-warms sessions. The single-session guard is a deliberate MVP scope cut, not an architectural limit; this RFC lifts it. diff --git a/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md index b7724c105c..ecbd06a734 100644 --- a/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md @@ -4,27 +4,28 @@ Status: proposed ## Problem -The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The persistence RFC chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. +The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence RFC](../implemented/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. -The loop already appends an assembled `assistant/message` for each step. That is the event `deriveMessages()` uses for the next model request. In other words, the resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. +For successful steps that assemble completed content, the loop already appends an `assistant/message`. That is the event `deriveMessages()` uses for the next model request. In other words, the normal resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. Failed or aborted streams are different: partial assistant output may exist only as chunks, and empty max-token steps may produce no `assistant/message` at all. ## Proposal -Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. +Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. Scenarios that need partial failed-stream output must record that output in the replay fixture or accept that it is not part of completed conversation history. ACP `session/load` can replay prior assistant messages as complete content blocks instead of simulating the original token stream. A loaded transcript need not reproduce every historical delta; it must show the same completed assistant content and resume with a valid provider history. ## Acceptance criteria - `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed. -- Persistence docs no longer require every stream chunk to be stored verbatim. +- [Session persistence docs](../../../packages/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. - `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks. - `session/load` renders completed assistant messages from `assistant/message`. - Stored logs get much smaller and remain `seq`-contiguous without chunk holes. +- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up -The canonical user session no longer reconstructs the exact token stream of an old turn. That is acceptable for resume and load, where completed message content is the user-visible state. Tests that need exact deterministic streams should own that fixture directly instead of smuggling it through the durable session format. +The canonical user session no longer reconstructs the exact token stream of an old turn. It also loses partial assistant output from failed or aborted streams unless another event or fixture records it. That is acceptable for resume and load, where completed message content is the user-visible state. Tests that need exact deterministic streams should own that fixture directly instead of smuggling it through the durable session format. ## Related diff --git a/docs/rfc/proposed/2026-06-20-classify-support-packages.md b/docs/rfc/proposed/2026-06-20-classify-support-packages.md index 13f5a2b04a..c3947c1c61 100644 --- a/docs/rfc/proposed/2026-06-20-classify-support-packages.md +++ b/docs/rfc/proposed/2026-06-20-classify-support-packages.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -`packages/` is flat. Core product packages, provider integrations, tool implementations, example UI support, and snapshot-only replay support all sit at the same level and look equally publishable. `packages/README.md` already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more foundational than they are and forces publish/lint/doc scripts to special-case intent in prose or static lists. +`packages/` is flat. Core product packages, provider integrations, tool implementations, example UI support, and snapshot-only replay support all sit at the same level and look equally publishable. The [package README](../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more foundational than they are and forces publish/lint/doc scripts to special-case intent in prose or static lists. This is not just cosmetic. A package's location currently says little about whether it is core API, an integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. diff --git a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md index 464eb9a154..143f3e822a 100644 --- a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md @@ -21,6 +21,7 @@ If analytics become real, add a projection helper or a dedicated telemetry store - The loop records durable failures only as `turn/end { kind: 'error' }` and reports live diagnostics through `agent/error`. - ACP snapshots and persistence tests stop asserting trace-only lines. - Documentation explains where token usage and operational errors are observed if they remain available. +- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md index cafb70a948..f346b8bb91 100644 --- a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md @@ -4,19 +4,19 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. `scripts/publint-all.ts` has a static list of publishable packages. The package cookbook tells authors to update several files. The package README carries a hand-written dependency graph. CI and development docs can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. +Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../.github/workflows/ci.yml) and [development docs](../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data that already exists in `package.json`, workspace globs, or package metadata. ## Proposal -Make package/gate inventories discoverable. Publishability should come from package metadata or classification, not from a static array in a script. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make package/gate inventories discoverable. Publishability should come from explicit package classification metadata, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. This pairs well with [classifying support packages](2026-06-20-classify-support-packages.md), because discovery needs to know which packages are product-publishable, support-only, private, or examples. ## Acceptance criteria -- `publint-all` discovers publishable packages from manifests or a single classification source. +- `publint-all` discovers publishable packages from manifests plus a single classification source. - Adding a package does not require editing a static package list for every gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md b/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md index f8a0853524..fe58449645 100644 --- a/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md @@ -18,7 +18,7 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f - `initialize` does not advertise load support. - The `session/load` handler, loading-id tracking, cwd preflight for loaded sessions, and load replay tests are removed. - Snapshot fixtures no longer rely on load replay presentation. -- ACP docs describe fresh-session support only. +- [ACP docs](../../../packages/acp/README.md) describe fresh-session support only. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md index 897f23a2e8..05dacc100b 100644 --- a/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented RFC deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. +The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering RFC](../implemented/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway. @@ -20,7 +20,7 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 - `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`. - `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup. - Bash result presentation no longer parses exit status for terminal pills. -- The implemented terminal-rendering RFC is superseded or moved to rejected with this proposal linked. +- The implemented [rich ACP bash rendering RFC](../implemented/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md index 2df48cd3cc..9f55e6e6f8 100644 --- a/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md @@ -20,7 +20,7 @@ This proposal can land independently of [foreground-only bash](2026-06-20-foregr - `OutputCollector` keeps bounded buffers only and deletes the temp-file machinery. - `renderResult()` reports truncation without a filesystem path. - Tests cover tail truncation and no longer assert full-output file contents. -- Security docs stop treating private spill files as a model-visible interface. +- Security guidance in [root AGENTS.md](../../../AGENTS.md) stops treating private spill files as a model-visible interface. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md index 356fb20c31..4bb5938b59 100644 --- a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md @@ -20,7 +20,8 @@ The invariants plugin should enforce that step-scoped events have valid positive - The loop has no `closeStep()` finalization path. - ACP snapshots and persistence contract fixtures stop expecting step-boundary lines. - `deriveMessages()` and replay derive the same message history from step-scoped events. -- The event taxonomy docs describe turns as the durable boundary and steps as a field on step-scoped records. +- The [event taxonomy docs](../../architecture.md) describe turns as the durable boundary and steps as a field on step-scoped records. +- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md index 35fcb3fc63..0a5d1474f7 100644 --- a/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md +++ b/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md @@ -20,6 +20,7 @@ If lineage returns, decide then whether it belongs in the immutable header, a se - JSONL and SQLite metadata schemas stop storing parent-session ids. - Resume and list APIs no longer round-trip `parentSession`. - Docs and tests remove fork-lineage claims that are not backed by a production consumer. +- The session format version, backend schema versions, and recorded fixtures are refreshed as needed; non-current stored data is rejected per the pre-release format policy, with no migration path. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md index d2e8466d07..bd760e7922 100644 --- a/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md @@ -20,7 +20,7 @@ The implementing PR should update the [capability seams](../implemented/2026-06- - `dsh-session` exports the persistence service type, coordinator, and contract helpers. - JSONL and SQLite backend packages depend on `dsh-session` directly. - `agent-loop` resume uses the session-owned service key. -- Persistence RFCs and package docs explain why backend implementations remain separate. +- [Session persistence](../implemented/2026-06-14-session-persistence.md), [shared persistence write coordinator](../implemented/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../packages/session-persistence/README.md) explain why backend implementations remain separate. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-foreground-only-bash.md b/docs/rfc/proposed/2026-06-20-foreground-only-bash.md index 42401151a1..3197e916ca 100644 --- a/docs/rfc/proposed/2026-06-20-foreground-only-bash.md +++ b/docs/rfc/proposed/2026-06-20-foreground-only-bash.md @@ -4,13 +4,13 @@ Status: proposed ## Problem -The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. Recent work added owner-token isolation because global predictable task ids become a cross-session read/kill hazard. +The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. -The cookbook already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. +The [tool cookbook](../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. ## Proposal -Temporarily collapse `bash` to foreground-only execution. Remove `run_in_background`, `bash_output`, `bash_kill`, background task ownership, incremental task reads, completion injection, and task-listener APIs from the public bash executor seam. Long commands can still run with an explicit timeout; a command that needs to outlive a model step is not supported until a generic task service exists. +Temporarily collapse `bash` to foreground-only execution. Remove the model-facing `run_in_background` schema field, the `bash_output` and `bash_kill` tools, background task ownership, incremental task reads, completion injection, and task-listener APIs from the bash executor seam. The `BashExecRequest` request type is already foreground-shaped; the removal surface is the tool schema plus the executor's background-task methods. Long commands can still run with an explicit timeout; a command that needs to outlive a model step is not supported until a generic task service exists. If long-running tasks return later, implement them once as a capability-agnostic task layer that owns ids, authorization, polling, cancellation, completion notifications, and any UI affordances. Bash can then opt into that layer like any other tool. @@ -20,7 +20,7 @@ If long-running tasks return later, implement them once as a capability-agnostic - `BashExecutor` exposes `resolve()` and foreground `run()` only. - `@deepseek-ai/dsh-bash-local` no longer tracks background task maps, owner tokens, task listeners, or incremental output cursors. - ACP and snapshot fixtures no longer mention `bash_output` or `bash_kill`. -- The cookbook either removes the background example or redirects long-running work to the future generic task RFC. +- The [tool cookbook](../../cookbook/adding-a-tool.md) either removes the background example or redirects long-running work to a future generic task proposal. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/2026-06-20-providerless-example-base.md index cefcccea08..a824dd4fa1 100644 --- a/docs/rfc/proposed/2026-06-20-providerless-example-base.md +++ b/docs/rfc/proposed/2026-06-20-providerless-example-base.md @@ -4,23 +4,23 @@ Status: proposed ## Problem -The examples have two shared base files: `examples/base-core.yml` is providerless, while `examples/base.yml` includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. +The examples have two shared base files: [examples/base-core.yml](../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. ## Proposal -Rename the providerless core to `examples/base.yml` and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete `base-core.yml`. +Rename the providerless core to [examples/base.yml](../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../examples/base-core.yml). The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. ## Acceptance criteria -- `examples/base.yml` is providerless. -- `examples/base-core.yml` is deleted. +- [examples/base.yml](../../../examples/base.yml) is providerless. +- [examples/base-core.yml](../../../examples/base-core.yml) is deleted. - Real demo configs explicitly add the DeepSeek adapter. - Snapshot replay config includes the same providerless base and its replay adapter. -- README and RFC references stop explaining "base = base-core plus adapter". +- The [examples README](../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter". ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md index dfc2da2677..e793b7bde6 100644 --- a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md @@ -16,7 +16,7 @@ Delete public `abort()` and `whenIdle()`, the tests that exercise them as standa ## Acceptance criteria -- `Agent` exposes `send()`, `inject()`, `cancel()`, status, options, session, and identity, with no public `abort()` or `whenIdle()`. +- `Agent` exposes no public `abort()` or `whenIdle()`; if [retiring mid-turn steering](2026-06-20-retire-mid-turn-steering.md) has not landed, `steer()` remains part of the message surface. - ACP cancellation continues to call `cancel()`. - Agent teardown continues to await quiescence through handle disposal. - Tests cover cancellation and disposal as the two supported stop paths. @@ -24,3 +24,7 @@ Delete public `abort()` and `whenIdle()`, the tests that exercise them as standa ## What we give up A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps private loop mechanics public. + +## Related + +This RFC only removes the stop/quiescence methods. If it lands before [retiring mid-turn steering](2026-06-20-retire-mid-turn-steering.md), `steer()` remains part of the `Agent` message surface; if the steering RFC lands first, the resulting surface is `send()`, `inject()`, `cancel()`, status, options, session, and identity. diff --git a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md index 7b2aa2d748..cbb299dfca 100644 --- a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md @@ -4,13 +4,13 @@ Status: proposed ## Problem -The loop records the canonical transcript in `SessionEvent` and also emits a parallel set of live `agent/*` mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, `agent/queued`, and `agent/steering`. The mirrors make consumers choose between two sources of truth. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI is the only production consumer that still renders primarily from the mirror stream. +The loop records the canonical transcript in `SessionEvent` and also emits a parallel set of live `agent/*` mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, `agent/stream-chunk`, and `agent/steering`. The mirrors make consumers choose between two sources of truth. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI is the only production consumer that still renders turn boundaries and the token stream from the mirror events; it already renders tool calls and results from `session/event`. This duplication is not free. Every lifecycle change has to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also make failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. ## Proposal -Make `session/event` the live transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. Keep agent lifecycle/control events that are not transcript data: `agent/created`, `agent/disposed`, `agent/status`, and `agent/error`. Keep any live-only token stream only if the canonical log separately stops storing chunks; otherwise `assistant/chunk` session events cover that too. +Make `session/event` the live transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. Keep agent lifecycle/control events that are not transcript data: `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued`. `agent/queued` is an inbox acknowledgement rather than a transcript mirror: it fires before any durable event exists, and cancelled queued work may never enter the log. Remove the duplicate durable-boundary mirrors from the agent event taxonomy. If a UI wants an agent handle from a session event, it can keep a small map from session id to agent built from `agent/created`/`agent/disposed`, or the registry can offer an explicit lookup. The canonical record remains the event-sourced session log. @@ -18,6 +18,7 @@ Remove the duplicate durable-boundary mirrors from the agent event taxonomy. If - ACP and stdio render transcript content from `session/event`. - `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, and `agent/steering` are removed or reduced to private implementation details. +- `agent/queued` is either retained and documented as live-only inbox/control state, or deleted in a separate proposal that names the queue-acknowledgement capability loss. - Tests assert the persisted event stream, not a second mirror stream, for turn and step ordering. - Documentation presents `SessionEvent` as both the durable source and the live transcript feed. diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md index e4dc94e2be..aedb1b9a40 100644 --- a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -20,7 +20,7 @@ Stdout goldens remain unchanged; they are the editor-facing projection and are n - The snapshot test derives the expected session log from `session.jsonl` for `recorded: true` scenarios. - Authored sidecar scenarios keep explicit session goldens when needed. - Orphan-fixture guards understand which files are required by scenario kind. -- The snapshot-test RFC is updated to describe the reduced fixture set. +- The [ACP snapshot tests RFC](../implemented/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md index 6b699eb771..45092fc1e6 100644 --- a/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md @@ -6,13 +6,13 @@ Status: proposed The agent exposes two user-message paths that look close but have different lifecycle semantics: `send()` queues a normal user turn, while `steer()` injects a message between steps of the currently running turn and falls back to `send()` when idle. That distinction leaks through the whole stack: `Agent.steer()` is public API, the session log has a durable `steering/message` event, the agent event taxonomy has `agent/steering`, the loop maintains a steering FIFO beside the queued-message FIFO, cancellation clears both queues, and `deriveMessages()` has to render steering as a tagged synthetic user message rather than a normal prompt. -The continuation seam amplifies the cost. `agent/turn-continuation` defaults to `hadToolCalls || steeringInjected`, so a same-turn steering message can force the loop to call the model again even if the model did not ask for tools. The comments name future `/goal`, `/loop`, and budget-guard uses, but the current repo has no production listener. The only production UI that mentions steering is the stdio demo; ACP already sends prompts through the ordinary queue while a turn is running. +The continuation seam amplifies the cost. `agent/turn-continuation` defaults to `hadToolCalls || steeringInjected`, so a same-turn steering message can force the loop to call the model again even if the model did not ask for tools. The comments name future `/goal`, `/loop`, and budget-guard uses, but the current repo has no production listener; only tests register the waterfall. Separately, the only production UI that calls `steer()` is the stdio demo. ACP already sends prompts through the ordinary queue while a turn is running. ## Proposal Delete mid-turn user steering for now. `Agent.send()` becomes the single public way to submit user content; when the agent is running, the content waits for the next turn. The loop continues within a turn only for tool calls, not because a user typed while a step was running. A caller that wants to interrupt the current turn uses `cancel()` and then `send()`. -Remove `Agent.steer()`, the steering FIFO, `steering/message`, `agent/steering`, steering-derived continuation, and the cancellation logic that distinguishes queued messages from steering messages. Revisit `agent/turn-continuation` at the same time: if there is still no production listener, remove the waterfall too and let the loop continue only on the closed set of reasons it owns. If a real budget or goal plugin later needs forced continuation, it should reintroduce a narrower seam with that plugin as the concrete consumer. +Remove `Agent.steer()`, the steering FIFO, `steering/message`, `agent/steering`, steering-derived continuation, and the cancellation logic that distinguishes queued messages from steering messages. Remove `agent/turn-continuation` in the same change unless the implementing PR discovers a production listener; without steering, the current repo has no concrete continuation consumer left. If a real budget or goal plugin later needs forced continuation, it should reintroduce a narrower seam with that plugin as the concrete consumer. ## Acceptance criteria @@ -20,7 +20,9 @@ Remove `Agent.steer()`, the steering FIFO, `steering/message`, `agent/steering`, - The durable session event vocabulary no longer contains `steering/message`. - `deriveMessages()` renders normal user messages and context injections, with no steering tag path. - The loop has one queued-message FIFO and no same-turn user-message continuation path. +- `agent/turn-continuation` is removed or narrowed to a named production consumer. - The stdio UI and docs describe input while running as queued next-turn input. +- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up @@ -28,4 +30,4 @@ A user cannot add same-turn steering content while a model is between tool steps ## Related -This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering leaves tool calls as the only reason a turn contains multiple model steps. +This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps. diff --git a/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md b/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md index 6c3c88a2cc..0558975949 100644 --- a/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. A separate proposed RFC still tracks the unfinished permission-ownership piece. +The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. The product has not yet proven it needs concurrent editor conversations over one harness process. The snapshot replay tier also avoids concurrent model streams because its replay entries are positional; concurrency would require keying replay by request instead of by stream order. @@ -19,8 +19,8 @@ Remove the multi-session maps and demux where a single `SessionRecord | undefine - ACP has one active session record per connection. - `session/new` and `session/load` reject while that record exists. - Event handlers no longer demux across a `Map`. -- Multi-session tests are removed or moved to a rejected/superseded proposal. -- The existing [multi-session ACP proposal](2026-06-14-acp-multi-session.md) is updated to link this RFC if rejected. +- Multi-session tests are removed or moved under the proposal that continues to defend multiplexing. +- The existing [multi-session ACP proposal](2026-06-14-acp-multi-session.md) is updated to link this RFC while both proposals remain live. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md index a0a16031c5..388af09716 100644 --- a/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md @@ -19,8 +19,9 @@ This makes the persisted turn boundary simple: a completed `turn/end` is the che - `TurnEndReasonMap` drops the `interrupted` variant. - `interruptedTurnClosers()` and its tests disappear. - The persistence coordinator's repair hook truncates backend-specific torn/open tail state without appending closers. -- Persistence docs say load returns the last completed turn, plus no partial final turn. +- [Session persistence docs](../../../packages/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. - Snapshot and contract tests update together with the behavior they pin. +- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy, with no migration path. ## What we give up diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 376f02c9df..9d439fba98 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' // publint every publishable package (vendor/ is private upstream code and // examples/ are not packages; both are out of scope). -// TODO(package-inventory): derive this from package metadata/classification. +// TODO(package-inventory): derive this from explicit package classification metadata. const packages = [ 'packages/llm', 'packages/session', From 5a6243900da5705c1a6e6609640efd2f015215de Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:29:42 +0800 Subject: [PATCH 33/87] fix(doc-sync): close verify-type-equiv scan gap; correct persistence prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found verify-type-equiv only scanned docs the manifest already named, so a type-equiv block in an unmanifested doc was silently skipped — defeating the 1:1 guarantee. Scan all docs in the markdown glob scope instead, so an orphan block in any doc is caught. Also parse `abstract class` in blockSymbol (matches sourceDeclaration's class support). persistence.md listed the SessionPersistence surface as create/append/load/list; the abstract service also exposes has/delete. AGENTS.md's doc-sync command summary omitted verify-md-links and verify-type-equiv. --- AGENTS.md | 2 +- docs/core-data-structures/persistence.md | 4 +-- scripts/verify-type-equiv.ts | 35 ++++++++++++++++++------ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00e83edb6b..d6c56e2e9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/archit # matches the interface Events declarations in source pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/README.md, AGENTS.md (one line per paragraph) -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 8bc132ffb2..f232fae1c9 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. -The seam is a textbook [capability seam](../rfc/implemented/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -55,7 +55,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (create/append/load/list/has/delete over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 5de48b97f9..54938e8b66 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -23,11 +23,21 @@ */ import { readFileSync, existsSync } from 'node:fs' -import { relative, resolve } from 'node:path' +import { resolve } from 'node:path' +import { glob } from 'node:fs/promises' import ts from 'typescript' const root = resolve(import.meta.dirname, '..') +/** + * Markdown globs scanned for ` ```ts type-equiv ` blocks — the SAME scope + * doc-typecheck uses. Scanning every doc (not only the docs the manifest names) + * is what makes the 1:1 guarantee real in both directions: a type-equiv block + * added to a doc with NO manifest entry is still discovered here and reported as + * an orphan, instead of being silently skipped. + */ +const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/README.md'] + /** One manifest entry: a documented type-equiv block and its source symbol. */ interface ManifestEntry { /** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */ @@ -71,7 +81,7 @@ function stripExport(code: string): string { /** Parse the declared symbol name from a type-equiv block body. */ function blockSymbol(code: string): string | null { - const m = /(?:export\s+(?:default\s+)?)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) + const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) return m?.[1] ?? null } @@ -133,13 +143,22 @@ const entries = manifest.entries // doc, but at most once per doc). const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}` -// Collect every type-equiv block across the docs the manifest references. -const docFiles = [...new Set(entries.map(e => e.doc))] -const missingDocs = docFiles.filter(d => !existsSync(resolve(root, d))) -const blocks: EquivBlock[] = docFiles.filter(d => existsSync(resolve(root, d))).flatMap(extractEquivBlocks) +// Collect every type-equiv block across ALL docs in scope — not only the docs +// the manifest names — so a block in an unmanifested doc is found and reported +// as an orphan rather than silently skipped. +const docSet = new Set() +for (const pattern of MARKDOWN_GLOBS) { + for await (const match of glob(pattern, { cwd: root })) docSet.add(match) +} +const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) const errors: string[] = [] -for (const d of missingDocs) errors.push(`manifest references ${d}, which does not exist`) +// A manifest entry naming a doc that does not exist (or is outside the scanned +// scope, so no block could ever match it) is an error in its own right. +for (const d of [...new Set(entries.map(e => e.doc))]) { + if (!existsSync(resolve(root, d))) errors.push(`manifest references ${d}, which does not exist`) + else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`) +} // Duplicate-block guard: the same symbol twice in one doc is ambiguous. const blockByKey = new Map() @@ -204,5 +223,5 @@ if (errors.length === 0) { console.error('verify-type-equiv: type-equiv verification failed:') for (const e of errors) console.error(` ${e}`) -console.error(`\n(checked ${blocks.length} block(s) across ${docFiles.map(d => relative(root, resolve(root, d))).length} doc(s); manifest at scripts/type-equiv.manifest.json)`) +console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`) process.exit(1) From bcf255fc57cb3b6cfca3cd4b1df79ee33815f1bd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:50:58 +0800 Subject: [PATCH 34/87] docs: fix review findings in ACP checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session/close: ⚠️→❌ (no handler; SDK dispatch returns method_not_found — disconnect/disposal teardown is not the per-session method) - Codex plan: ⚠️→✅ (CodexEventHandler.updatePlan emits the stable `plan` update; the plan-as-text note was stale) - Codex elicitation: ✅→⚠️ (maps onto session/request_permission; does not call elicitation/create|complete) - Overview: qualify the "both adapters ship" clause — neither drives the client terminal/* family and only Claude uses fs/* - Remove a stray sentinel that rendered literally at EOF --- docs/acp-feature-support.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/acp-feature-support.md b/docs/acp-feature-support.md index a0e1dd26cd..d166ad6185 100644 --- a/docs/acp-feature-support.md +++ b/docs/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), the client **filesystem** and **terminal** method families, **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship. See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -22,7 +22,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. | | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | -| `session/close` | S | ⚠️ | ✅ | ✅ | No explicit `session/close` handler; the bridge tears a session down on client disconnect / disposal, not on demand per session. | +| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | | `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | | `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | | `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes not modeled (see [§6 Modes](#6-session-modes--config-options--models)). | @@ -47,7 +47,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. | | `terminal/kill` | S | ❌ | ❌ | ❌ | As above. | | `terminal/release` | S | ❌ | ❌ | ❌ | As above. | -| `elicitation/create` · `elicitation/complete` | U | ❌ | ✅ | ✅ | Structured user-input forms; both adapters use the `unstable_*` elicitation methods (mostly to surface MCP server elicitations). | +| `elicitation/create` · `elicitation/complete` | U | ❌ | ✅ | ⚠️ | Structured user-input forms. Claude calls the `unstable_*` elicitation methods (to surface MCP server elicitations); Codex does NOT — its `CodexElicitationHandler` maps elicitations onto `session/request_permission` instead. | ## 3. Capabilities @@ -83,7 +83,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. | | `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | | `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. | -| `plan` | S | ❌ | ✅ | ⚠️ | No agent plan emitted. Claude emits real plan entries; Codex renders plan as plain message text. | +| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | | `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | | `config_option_update` | S | ❌ | ✅ | ✅ | No config options. | @@ -160,4 +160,3 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra - Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo. - Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp). - Bridge: [`packages/acp/README.md`](../packages/acp/README.md), [`packages/acp/src/index.ts`](../packages/acp/src/index.ts), and the ACP RFCs under [`docs/rfc/`](rfc/README.md). - From f5e61417ee5a541f97595dd2c633a228613cf29a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:08:17 +0800 Subject: [PATCH 35/87] docs: move ACP checklist into packages/acp Co-locate the ACP feature support checklist with the bridge package (packages/acp/acp-feature-support.md) and rewrite its relative links for the new depth. Broaden the doc-sync globs (doc-typecheck, verify-md-wrap, verify-md-links) from packages/*/README.md to packages/*/*.md so a package-level doc beyond the README stays under the drift gates, and update the AGENTS.md prose describing that scope. --- AGENTS.md | 8 ++++---- packages/AGENTS.md | 2 +- {docs => packages/acp}/acp-feature-support.md | 8 ++++---- scripts/doc-typecheck.ts | 2 +- scripts/verify-md-links.ts | 2 +- scripts/verify-md-wrap.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) rename {docs => packages/acp}/acp-feature-support.md (95%) diff --git a/AGENTS.md b/AGENTS.md index 424780bf74..94decaaa98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,11 @@ pnpm run knip # dead-code / unused-dependency check pnpm run publint # package.json publish-correctness check (publishable packages/*) pnpm run hygiene # knip + publint + workspace constraints pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, - # packages/*/README.md (doc/code drift gate) + # packages/*/*.md (doc/code drift gate) pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/architecture.md # matches the interface Events declarations in source pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, - # docs/**/*.md, packages/*/README.md, AGENTS.md (one line per paragraph) + # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton @@ -174,11 +174,11 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/README.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, and checks that every relative Markdown cross-link resolves — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, and checks that every relative Markdown cross-link resolves — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Document the CURRENT state — the "what" and "why" — never the PROCESS or HISTORY of how it got there.** A comment, JSDoc, or doc paragraph describes what the code *is* and why it is that way, as if it had always been so. Do NOT narrate the change that produced it: no "previously X, now Y", "changed from", "used to", "this replaces", "the old map", "renamed", "moved here", "as of this PR", or "(was …)". **In particular, NEVER name the change unit a reader cannot see — the PR, commit, or stack position that introduced the code — in a comment, JSDoc, OR a test name/description.** A `// (PR D's per-agent teardown)` aside, a `* Tests for the cancel primitive (PR C).` module doc, or an `it('… identity no longer matters')` title that only makes sense relative to a prior design are all the same violation: the reader of the current tree has no "PR D" or "old design" to anchor against, and the reference rots the moment the stack merges. Name the *mechanism* (`the session's AgentHandle teardown`), not the PR. Such phrasing rots the instant the next change lands, and a reader of the current code does not need the diff narrated in prose — that belongs in the commit message, the PR description, or an RFC (the durable home for "why we moved away from X"). Write "the owner token lives on the task in the executor" — not "ownership *now* lives on the executor instead of a plugin-local map". When a contrast genuinely aids understanding (a non-obvious choice between live alternatives), frame it against the alternative as a standing fact ("stored on the executor, NOT the tool plugin, so it survives an HMR reload"), not against the codebase's past. The same rule governs review-fix commits: the *commit message* records what the review caught; the *code comment* it touches states only the resulting truth. RFCs (`docs/rfc/`, grouped into `proposed/` / `implemented/` / `rejected/`) record the *why* behind choices a future reader would otherwise re-litigate (the vendoring policy, event-sourcing, the schema DSL are the existing examples). A PR that introduces such a decision — a new third-party runtime dependency over the vendoring default, a cross-package contract, a security/isolation model, a deviation from a documented architecture rule — writes the RFC in `implemented/` **in the same PR**, and links it from the relevant code. A proposal for future work not yet built goes in `proposed/`. A PR whose changes are mechanical, self-evident, or already covered by an existing RFC needs none — do not manufacture an RFC for a routine change. When unsure, the test is: would a competent maintainer six months from now ask "why was it done this way?" and be unable to answer from the code alone? If yes, write it. See [docs/rfc/README.md](docs/rfc/README.md) for the naming scheme and [docs/AGENTS.md](docs/AGENTS.md) for the cross-link convention. -**Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. `pnpm run verify-md-wrap` (part of `doc-sync`) enforces this across `README.md`, `docs/**/*.md`, `packages/*/README.md`, and `AGENTS.md` / `packages/AGENTS.md`; `pnpm run verify-md-links` (also part of `doc-sync`) checks that every relative cross-link in those files resolves. +**Markdown is not hard-wrapped**: write one line per paragraph and let the editor soft-wrap. Hard line breaks mid-paragraph make docs harder to edit and diff — a one-word change reflows and re-diffs the whole paragraph. This applies to prose only: leave fenced code blocks, tables, and list structure intact (a wrapped list item folds to one line per bullet). Code comments / JSDoc are exempt — they stay under the linter's column limit. `pnpm run verify-md-wrap` (part of `doc-sync`) enforces this across `README.md`, `docs/**/*.md`, `packages/*/*.md`, and `AGENTS.md` / `packages/AGENTS.md`; `pnpm run verify-md-links` (also part of `doc-sync`) checks that every relative cross-link in those files resolves. **Editing these instructions**: `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (at the repo root and in `packages/`). Always edit `AGENTS.md` — never write through the `CLAUDE.md` symlink or replace it with a regular file. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 31f42c26c9..ae7c7d5a17 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -13,6 +13,6 @@ Naming notes: - A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above) - `src/types.ts` contain only types — no runtime code - Tests live at package level under `tests/`, not `src/__tests__/` -- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/README.md`, verifies the event-taxonomy table, and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. +- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, verifies the event-taxonomy table, and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. diff --git a/docs/acp-feature-support.md b/packages/acp/acp-feature-support.md similarity index 95% rename from docs/acp-feature-support.md rename to packages/acp/acp-feature-support.md index d166ad6185..04b1a2f258 100644 --- a/docs/acp-feature-support.md +++ b/packages/acp/acp-feature-support.md @@ -1,6 +1,6 @@ # ACP feature support checklist -A structured inventory of [Agent Client Protocol](https://agentclientprotocol.com) (ACP) features and where the harness's ACP bridge ([`@deepseek-ai/dsh-acp`](../packages/acp/README.md)) stands on each. The bridge exposes the harness agent as an ACP **server** (the agent side of an editor↔agent connection), so "supported" below means *the bridge implements the agent's half* — answering an agent method, advertising a capability, or calling a client method. +A structured inventory of [Agent Client Protocol](https://agentclientprotocol.com) (ACP) features and where the harness's ACP bridge ([`@deepseek-ai/dsh-acp`](README.md)) stands on each. The bridge exposes the harness agent as an ACP **server** (the agent side of an editor↔agent connection), so "supported" below means *the bridge implements the agent's half* — answering an agent method, advertising a capability, or calling a client method. ## Scope @@ -92,7 +92,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs ## 5. Tool-call rendering -Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). +Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| @@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them | Feature | Stable | Bridge | Notes | |---|---|---|---| | `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. | -| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](rfc/proposed/2026-06-14-acp-multi-session.md). | +| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md). | | Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. | | `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. | | Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. | @@ -159,4 +159,4 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra - Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo. - Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp). -- Bridge: [`packages/acp/README.md`](../packages/acp/README.md), [`packages/acp/src/index.ts`](../packages/acp/src/index.ts), and the ACP RFCs under [`docs/rfc/`](rfc/README.md). +- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../docs/rfc/README.md). diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 10068eb792..fda086dbf5 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -97,7 +97,7 @@ function tempTsconfig(): string { }) } -const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/README.md'] +const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md'] const files: string[] = [] for (const pattern of markdownGlobs) { diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 35e42452ca..be1a80f86a 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -48,7 +48,7 @@ const root = resolve(import.meta.dirname, '..') const PATTERNS = [ 'README.md', 'docs/**/*.md', - 'packages/*/README.md', + 'packages/*/*.md', 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 169b06b15b..dbb1e69235 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -18,7 +18,7 @@ * A wrapped paragraph inside a list item or blockquote is still a `paragraph` * node, so those are caught too. Scope mirrors doc-typecheck plus the two * AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself - * lives there): README.md, docs/** /*.md, packages/* /README.md, AGENTS.md, + * lives there): README.md, docs/** /*.md, packages/* /*.md, AGENTS.md, * packages/AGENTS.md. The root and packages/ CLAUDE.md are symlinks to the * AGENTS.md files, so they are deduped by real path. * @@ -36,7 +36,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ -const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/README.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] /** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { From ee494969afe70146b898d52842794c39e985e08c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:21:42 +0800 Subject: [PATCH 36/87] docs: add @mode tags + enrich JSDoc on harness event declarations Annotate all 24 harness events across the 5 event-declaring packages with an explicit `@mode emit|waterfall|parallel` tag and self-contained JSDoc, so the generated cordis catalog can render each entry's mode and prose from source alone. --- packages/agent/src/types.ts | 66 ++++++++++++++++++++++++----- packages/llm/src/index.ts | 18 ++++++-- packages/session/src/index.ts | 17 ++++++-- packages/system-prompt/src/index.ts | 13 +++++- packages/tools/src/index.ts | 10 +++-- 5 files changed, 102 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0ebf582bda..d6b35b97a1 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -132,48 +132,94 @@ export interface Agent { declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- - /** An agent was registered. */ + /** + * An agent was registered in the {@link AgentRegistry} and is ready to + * receive messages. + * @mode emit + */ 'agent/created'(agent: Agent): void - /** An agent was disposed. */ + /** + * An agent was disposed and removed from the registry; its fiber and any + * in-flight turn have been torn down. + * @mode emit + */ 'agent/disposed'(agent: Agent): void - /** Agent status changed (idle/running/disposed). */ + /** + * 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. + * @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. + * @mode emit */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void // ---- turn/step boundaries (emit) ---- + /** + * A turn began. `turn` is the 1-based turn number within the session. + * @mode emit + */ 'agent/turn-start'(agent: Agent, turn: number): void + /** + * A turn ended. `reason` distinguishes a clean stop from a truncated or + * aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`). + * @mode emit + */ 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void + /** + * A step (one model call plus its tool dispatch) began. `step` is 1-based + * within the turn; a turn runs one or more steps. + * @mode emit + */ 'agent/step-start'(agent: Agent, turn: number, step: number): void + /** + * A step ended. + * @mode emit + */ 'agent/step-end'(agent: Agent, turn: number, step: number): void // ---- interception seams (waterfall) ---- /** - * Waterfall: mutate the fully-assembled GenerateOptions before the model - * call (hooks, compaction, model switching, tool filtering, …). + * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the + * model call (hooks, compaction, model switching, tool filtering, …). Call + * `next()` to delegate, or return without it to short-circuit. + * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise /** - * Waterfall: post-process the assembled assistant message before tool - * dispatch (validation, content rewriting, …). + * Waterfall: post-process the assembled assistant {@link Message} before + * tool dispatch (validation, content rewriting, …). + * @mode waterfall */ 'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** * Waterfall: override the turn-continuation decision. The default * (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners * can force-continue (/goal, /loop) or force-stop (budget guards). + * @mode waterfall */ 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise): Promise // ---- streaming + tool notifications (emit) ---- - /** A raw stream chunk arrived (token-level UI/log feed). */ + /** + * A raw {@link StreamChunk} arrived from the model (token-level UI/log feed). + * @mode emit + */ 'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void - /** Steering content was injected into a running turn. */ + /** + * Steering content was injected into a running turn. + * @mode emit + */ 'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void - /** A step or turn errored. */ + /** + * 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. + * @mode emit + */ 'agent/error'(agent: Agent, turn: number, step: number, error: Error): void } } diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 460316ea50..aaa0f66460 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -23,11 +23,23 @@ declare module 'cordis' { } interface Events { - /** Waterfall around every streaming model call (retry, caching, routing). */ + /** + * 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. + * @mode waterfall + */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable - /** Waterfall around every non-streaming model call. */ + /** + * Waterfall around every non-streaming model call. Bound to the + * {@link LlmService}; call `next()` to delegate to the adapter. + * @mode waterfall + */ 'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise - /** An adapter was registered or unregistered. */ + /** + * An adapter was registered or unregistered (the model→adapter map changed). + * @mode emit + */ 'llm/adapter-change'(): void } } diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 57210c3431..65fbe01015 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -23,15 +23,24 @@ declare module 'cordis' { } interface Events { - /** A session was created in the store. */ + /** + * A session was created in the store. + * @mode emit + */ 'session/created'(session: Session): void - /** An event was appended to a session log (sync, fire-and-forget). */ + /** + * An event was appended to a session log (sync, fire-and-forget). This is + * the per-append feed a UI or invariant plugin tails. + * @mode emit + */ 'session/event'(session: Session, event: SessionEvent): void /** * Awaited durability checkpoint. The agent loop awaits * `ctx.parallel('session/flush', session)` at every turn end; persistence - * plugins (JSONL, SQLite) drain their write-behind - * buffers here and on fiber dispose. + * 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. + * @mode parallel */ 'session/flush'(session: Session): Promise | void } diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts index 25d87d2194..a5e6e4ed78 100644 --- a/packages/system-prompt/src/index.ts +++ b/packages/system-prompt/src/index.ts @@ -15,9 +15,18 @@ declare module 'cordis' { } interface Events { - /** Waterfall around prompt assembly — mutate/extend the assembly. */ + /** + * 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. + * @mode waterfall + */ 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise - /** A section or tool provider was registered or unregistered. */ + /** + * A section or tool provider was registered or unregistered (the assembly + * inputs changed). + * @mode emit + */ 'system-prompt/change'(): void } } diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index eee77445eb..5a17aa2b0c 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -36,11 +36,15 @@ declare module 'cordis' { * Waterfall around every tool execution — the single seam where sandbox, * permission, hook, and plan-mode plugins wrap or veto a call. Listeners * receive `(exec, next)`: call `next()` to proceed (possibly around your - * own logic), or return a ToolExecutionResult without calling `next()` - * to short-circuit (veto). + * own logic), or return a {@link ToolExecutionResult} without calling + * `next()` to short-circuit (veto). + * @mode waterfall */ 'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise - /** A tool was registered or unregistered. */ + /** + * A tool was registered or unregistered (the available tool set changed). + * @mode emit + */ 'tools/change'(): void } } From 552612622caf8cf715aced0612d58be9ea55bc17 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:23:05 +0800 Subject: [PATCH 37/87] docs: fold useful simplification RFCs from pr 74 --- docs/rfc/README.md | 3 ++ ...6-06-20-drop-unconsumed-llm-block-views.md | 44 ++++++++++++++++ ...-drop-unconsumed-registry-change-events.md | 50 +++++++++++++++++++ .../2026-06-20-prune-dead-seam-methods.md | 49 ++++++++++++++++++ .../2026-06-20-public-agent-stop-surface.md | 4 +- packages/acp/src/index.ts | 4 ++ packages/bash-local/src/run.ts | 6 ++- packages/tools/src/schema.ts | 9 +++- 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md create mode 100644 docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 5f2e25ae31..b75f95d0bf 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -35,6 +35,9 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Retire mid-turn steering](proposed/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](proposed/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Keep one public stop primitive](proposed/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | +| [Drop the unconsumed `streamBlocks()` assembled-view surface](proposed/2026-06-20-drop-unconsumed-llm-block-views.md) | 2026-06-20 | +| [Drop the unconsumed registry `*/change` events](proposed/2026-06-20-drop-unconsumed-registry-change-events.md) | 2026-06-20 | +| [Prune dead methods from the persistence and bash seams](proposed/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Drop durable step boundary events](proposed/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | | [Truncate interrupted final turns on load](proposed/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | | [Persist assembled assistant messages, not stream chunks](proposed/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md new file mode 100644 index 0000000000..a0fb71d40b --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md @@ -0,0 +1,44 @@ +# RFC: Drop the unconsumed `streamBlocks()` assembled-view surface on `dsh-llm` + +Status: proposed + +## Problem + +`LlmService` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: + +- `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. +- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../packages/llm/src/index.ts)). +- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../packages/llm/src/index.ts)). + +The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding the raw chunks through its own `BlockAssembler` so it can log raw chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` across `packages/*/src` and `examples/*/src` finds zero callers; the only references are the method itself, two doc comments, and two test files (`llm/tests/properties.spec.ts`, `agent-loop/tests/review-fixes.spec.ts`). + +This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: an entire assembled-view API with a property-tested contract, consumed by nothing but its own tests. It was built speculatively for "consumers that don't care about token-level deltas" that never materialized — the one real consumer cares about deltas precisely so it can log them. + +`streamBlocks()` also drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support the incremental in-order yield. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — never the streaming flush. With `streamBlocks()` gone, `flushReady`/`flushRemaining`/`flushed` are dead too. + +## Proposal + +Delete `streamBlocks()` and the assembler's streaming-flush machinery it alone drives: + +- Remove `LlmService.streamBlocks()` and its JSDoc. +- Remove `BlockAssembler.flushReady()`, `BlockAssembler.flushRemaining()`, and the `flushed` cursor field. +- Remove or rework the `flushReady`/`flushRemaining`-dependent tests: in `llm/tests/properties.spec.ts` the `flushReady() ++ flushRemaining() === blocks()` property, the strict-order property, and the "streaming and one-shot assembly agree on usage and finish" property (which pushes-then-flushes incrementally) all exercise the streaming-flush path; the `flushRemaining` cases in `llm/tests/assembler.spec.ts` and the three `streamBlocks` edge-case tests in `agent-loop/tests/review-fixes.spec.ts` likewise. Each is either deleted or, where it also asserts a non-flush invariant worth keeping (e.g. streaming vs one-shot agreeing on usage/finish), rewritten to use `push()` + `message()`/`result()` without the removed flush methods — the behavior pinned to the deleted methods goes, per AGENTS.md "tests document behavior, not golden truth". +- Update every doc/comment reference to `streamBlocks` — grep it across `docs/`, `packages/llm/README.md`, and source comments. `packages/llm/README.md` mentions it twice (the API-list row and the `BlockAssembler` "used by `streamBlocks()`/`generate()`" line); the `assembler.ts` module doc references it; and the retained `generate()` JSDoc currently reads "Same completion guarantees as `streamBlocks()`" — reword it to state the guarantee directly. The `ctx.llm` service-map row in [docs/architecture.md](../../../docs/architecture.md) (`stream()` / `streamBlocks()` / `generate()`) drops `streamBlocks()` too. The [property-based-testing RFC](../implemented/2026-06-11-property-based-testing.md) needs two edits: its motivating anecdote ("a `streamBlocks` ordering bug") is reworded to name the bug class (a block-assembly ordering bug) rather than a removed method, and its dsh-llm invariant list — which names `flushReady()+flushRemaining() ≡ blocks()` as a checked property — is updated to drop the removed-method invariant and keep only the ones the surviving assembler API (`push`/`blocks`/`message`/`result`) still supports. + +## Scope: why `generate()` and `llm/generate` stay + +`generate()` is not dead the same way: the twin-adapter e2e/unit suites (`llm-deepseek`, `llm-pi-ai`) use `ctx.llm.generate({...})` as a convenient one-shot driver to assert provider behavior, and `GenerateResult` / `assembler.result()` back it. Those adapter tests are the [twin-adapter design](../implemented/2026-06-13-twin-llm-adapters.md), explicitly out of scope for a simplification pass. Removing `generate()` would force adapter-test call sites to hand-drain `stream()`, which is churn in protected territory for a method that is at least a legitimate ergonomic driver. So this RFC deliberately stops at the surface that nothing — not even an out-of-scope test — consumes. If a later pass wants to also collapse `generate()`/`llm/generate`/`result()`, that is a separate decision with a real caller to migrate. + +## Acceptance criteria + +- `streamBlocks` and the assembler streaming-flush methods are gone; `pnpm run knip` reports no new dead exports. +- `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). +- `generate()`, `stream()`, `result()`, `blocks()`, `message()` are untouched and the loop behaves identically — verified by the unchanged ACP snapshot goldens. +- `packages/llm/README.md` and the module docs no longer mention `streamBlocks`. + +## Risks + +- **It removes a public method from a core vocabulary package.** A future plugin that wants "assembled blocks without the deltas" would have to re-add it (or call `generate()` and read `.message.content`). Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../AGENTS.md)) and that the obvious assembled-view need is already served by `generate()`, this is the right time to cut — re-adding a thin assembler wrapper later is trivial if a real consumer appears. +- **Low blast radius.** The change is confined to `dsh-llm`; no other package imports `streamBlocks` or the flush methods, so there is no cross-package ripple. + +The size is modest, but it is a clean, zero-production-impact removal of a speculative surface — the cheapest kind of correctness. diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md new file mode 100644 index 0000000000..16c1b8faf2 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md @@ -0,0 +1,50 @@ +# RFC: Drop the unconsumed registry `*/change` notification events + +Status: proposed + +## Problem + +Three registries each emit a "something changed" notification event that no production listener subscribes to: + +- `tools/change` — emitted by `ToolRegistry.register()` on register and disposal ([packages/tools/src/index.ts:302-304](../../../packages/tools/src/index.ts)). +- `system-prompt/change` — emitted by `SystemPrompt.section()` and `.tools()` ([packages/system-prompt/src/index.ts:86-110](../../../packages/system-prompt/src/index.ts)). +- `llm/adapter-change` — emitted by `LlmService.registerAdapter()` ([packages/llm/src/index.ts:98-100](../../../packages/llm/src/index.ts)). + +Grepping the three event names across `packages/*/src` and `examples/*/src` finds only the emit sites and their declarations — zero `ctx.on('.../change')` listeners in production. The only subscribers are each package's own spec file, and they subscribe purely to test that the emit fires. They are listed in the event taxonomy table ([docs/architecture.md](../../../docs/architecture.md)) as `emit` events, but nothing reacts to them. + +These events are speculative generality for a hypothetical reactive consumer (a UI that live-refreshes its tool palette, say) that does not exist. That alone would be a mild [drop-the-dead-summary](../implemented/2026-06-19-drop-mutable-session-summary.md)-style cut. What makes it worth an RFC is the machinery the events drag along: to emit `.../change` safely, each registry orders its generator effect so the rollback disposer is `yield`ed before the change-emit, specifically so a throwing change-listener unwinds the mutation instead of leaking a registry entry. Every one of the three carries a multi-line comment justifying this ordering, plus a dedicated "rollback when a change listener throws" test. That is a non-trivial correctness burden guarding a failure mode that only the tests' own injected listeners can trigger, because there are no real listeners. + +## Proposal + +Remove the three `*/change` events and the defensive machinery that exists only to make them safe: + +- Delete the `tools/change`, `system-prompt/change`, `llm/adapter-change` declarations from each package's `interface Events`. +- Delete the `ctx.emit('.../change')` calls. +- Simplify each `ctx.effect` generator: the mutation and its rollback disposer remain (HMR/disposal still need them), but the "yield rollback before the emit so a throwing listener rolls back" ordering comment and any emit-after-yield collapse to a plain `set`/`push` plus a `yield () => delete`/`splice`. No behavior an external observer can see changes, because nothing observes the events. +- Remove the "Emits `.../change` on register/unregister" sentence from the surviving registration-method JSDocs — these sit on methods that stay, so they go stale rather than vanish with the deleted code: `LlmService.registerAdapter` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)), `ToolRegistry.register` ([packages/tools/src/index.ts](../../../packages/tools/src/index.ts)), and both `SystemPrompt.section` and `SystemPrompt.tools` ([packages/system-prompt/src/index.ts](../../../packages/system-prompt/src/index.ts)). +- Delete or rewrite the tests that exist to exercise the events. The change-listener-rollback tests are deleted outright (the rollback behavior goes with the event). The positive emission-subscriber tests are handled case by case: `system-prompt/tests/system-prompt.spec.ts`'s "emits system-prompt/change ..." is deleted (its disposal coverage is duplicated by the separate "cleans up tool providers on fiber dispose" / "removes section when returned disposer is called directly" tests), but `llm/tests/service.spec.ts`'s "disposes adapter registration on adapter-change event emission" is the only test that calls the `registerAdapter()` returned disposer and asserts the adapter is removed (the HMR test at "unregisters adapters when the owning fiber is disposed" covers fiber disposal, a different path) — so it is rewritten to drop the event subscription while keeping the returned-disposer assertion, not deleted. Per AGENTS.md "tests document behavior, not golden truth". +- Update the event taxonomy table in [docs/architecture.md](../../../docs/architecture.md) (remove the three rows) and re-run `pnpm run verify-event-taxonomy`, which mechanically checks the table against source. Also remove the per-package README event rows that list them: [packages/tools/README.md](../../../packages/tools/README.md) (`tools/change`), [packages/system-prompt/README.md](../../../packages/system-prompt/README.md) (`system-prompt/change`), and [packages/llm/README.md](../../../packages/llm/README.md) (`llm/adapter-change`). The [doc-sync-enforcement RFC](../implemented/2026-06-11-doc-sync-enforcement.md), whose `verify-event-taxonomy` description names these three as the events that surfaced when the check landed, is reworded so its example does not point at removed events. + +## Why not keep them as a "registries announce changes" convention? + +That is the honest counter-argument: a microkernel where every registry announces its mutations is a clean, uniform reactive substrate, and a future live UI would want exactly this. Three considerations push the other way: + +1. **The harness already has a finer-grained feed for the one realistic consumer.** A UI live-renders from `session/event` and `agent/*`, not from registry mutations — tools/sections/adapters are registered at plugin-load time and effectively static during a session. The `.../change` events fire almost exclusively during boot and HMR, when nothing is watching. +2. **Pre-release stance.** [AGENTS.md](../../../AGENTS.md) says optimize for the correct foundation, not a speculative future; add the seam when a real consumer needs it. Re-adding an emit is one line; the cost today is the standing rollback-ordering burden on three hot registration paths. +3. **The events are not free — they shape the registration code.** Keeping them means keeping the throwing-change-listener invariant and its tests forever, for a listener that cannot exist until someone adds one. + +If a reactive consumer is later built, it should be reintroduced deliberately, as one coherent decision about which registries announce what (and possibly a single `registry/change` shape), not as three independently-grown emits nothing reads. + +## Acceptance criteria + +- The three events and their emits are gone; `pnpm run verify-event-taxonomy` passes against the updated table. +- HMR-safety tests still pass: disposing a contributing fiber still removes the tool/section/adapter (the rollback disposer is retained; only the change-emit and its throwing-listener guard are removed). +- `pnpm run test:coverage` stays 100% per-file. +- No production code path changes observable behavior (verified by unchanged ACP snapshot goldens and the echo-agent smoke test). + +## Risks + +- **Removing a documented emit event is a public-surface change.** It is in the taxonomy table, so it reads as deliberate API. But "declared and emitted" is not "consumed" — the same distinction that justified dropping the mutable summary. The taxonomy table is updated in the same change, so the docs do not drift. +- **A registry that genuinely wants change-notification later pays a small reintroduction cost.** Judged acceptable per the pre-release stance; the reintroduction is mechanical. + +This is a small-to-medium cut across three packages and, more valuably, it retires a standing correctness invariant that guards a consumer that does not exist. diff --git a/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md new file mode 100644 index 0000000000..d38583c60b --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md @@ -0,0 +1,49 @@ +# RFC: Prune dead methods from the persistence and bash capability seams + +Status: proposed + +## Problem + +Two capability seams ([interface / implementation / consumer](../implemented/2026-06-13-capability-seams.md)) carry abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. + +### `SessionPersistence.has()` and `.delete()` + +The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/src/index.ts:142-151](../../../packages/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/agent-loop/src/index.ts:176-194](../../../packages/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/acp/src/index.ts](../../../packages/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. + +`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/src/coordinator.ts:298-310](../../../packages/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../packages/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../packages/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. + +### `BashExecutor.get()` and `.list()` + +The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/src/index.ts:88-107](../../../packages/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash-local/src/index.ts:179-191](../../../packages/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/tests/service.spec.ts](../../../packages/bash/tests/service.spec.ts), [packages/bash-local/tests/executor.spec.ts](../../../packages/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/tool-bash/tests/tools.spec.ts](../../../packages/tool-bash/tests/tools.spec.ts), [packages/tool-bash/tests/integration.spec.ts](../../../packages/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. + +## Proposal + +Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them: + +- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../implemented/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. +- `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. +- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/README.md](../../../packages/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/README.md](../../../packages/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence-sqlite/README.md](../../../packages/session-persistence-sqlite/README.md), [packages/session-persistence-jsonl/README.md](../../../packages/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../docs/architecture.md), and the persistence prose in the [session-persistence RFC](../implemented/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../implemented/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/src/index.ts](../../../packages/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/src/coordinator.ts](../../../packages/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. + +## Why not keep them as "the seam should be complete"? + +The instinct that a persistence seam "should" offer delete, or a task executor "should" offer enumeration, is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). Each of these is one method to re-add the day a consumer needs it: + +- A session-management UI that deletes old sessions will want `delete()` — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now. +- A `bash_list` tool that shows the model its running tasks will want `list()` — add it with the tool. + +Re-adding a seam method with a live consumer is cheap and better-designed than the speculative version, because the consumer pins the contract. Carrying it unused means every implementation (and every future backend) must implement and test a method that does nothing. + +## Acceptance criteria + +- `has`/`delete`/`deleteStored` and `get`/`list` are gone from their seams, impls, and contract suites; `pnpm run knip` reports no new dead exports. +- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`ownerOf`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. +- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed methods are deleted with them). +- Seam READMEs and `docs/architecture.md` no longer list the removed methods. + +## Risks + +- **`delete()` is the kind of operation a product eventually wants.** True — but "eventually" is the point. Deleting it now and re-adding it against a real consumer is strictly better than shipping a guessed contract. The dual backends each shed a `deleteStored` impl, which is a bounded edit in otherwise-out-of-scope packages. +- **`list()` on the bash seam is the natural seed for a future `bash_list`.** Acknowledged in the [pre-release foundation stance](../../../AGENTS.md): add the seed when the tool lands. The executor still tracks tasks internally (the `tasks` map backs `ownerOf`/`readOutput`/`kill`); exposing an enumeration is a one-line re-add. +- **Low coupling.** Both removals are confined to their seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs. + +Modest size, but it converts two seams from "what an implementation must provide for nobody" back to "exactly what a consumer uses." diff --git a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md index e793b7bde6..8ff4ebb46b 100644 --- a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md @@ -6,13 +6,15 @@ Status: proposed The public `Agent` handle exposes three ways to reason about stopping work: `abort(reason?)`, `cancel(reason?)`, and `whenIdle()`. `abort()` kills only the in-flight step and leaves queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race; `whenIdle()` exposes the loop's private quiescence waiter to any consumer. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs bare `abort()` or `whenIdle()`. +The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code calls the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that call `abort()` interrupt an empty queue and can switch to `cancel(reason)`; the one steering re-delivery test that deliberately depends on queue preservation should drive the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is also deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default. + The extra surface area makes the loop carry public semantics that are mostly teardown internals. `whenIdle()` needs waiter state, special disposed-agent behavior, and a loop-exit promise so it resolves after quiescence rather than merely after a status flip. `abort()` has to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. ## Proposal Keep `cancel()` as the only public stop primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation can keep private abort controllers and quiescence promises, but they are not part of the plugin-facing `Agent` contract. -Delete public `abort()` and `whenIdle()`, the tests that exercise them as standalone API, and the docs that describe step-only abort as an embedding feature. The disposer remains async and still waits for the loop to stop; that guarantee moves entirely onto `AgentHandle.dispose()`. +Delete public `abort()` and `whenIdle()`, the tests that exercise them as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop; that guarantee moves entirely onto `AgentHandle.dispose()`. ## Acceptance criteria diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index d107c2c0cb..089379e83a 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -203,6 +203,10 @@ interface SessionRecord { * others are no-ops (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' diff --git a/packages/bash-local/src/run.ts b/packages/bash-local/src/run.ts index 46d48cf5fd..8a8d2065d2 100644 --- a/packages/bash-local/src/run.ts +++ b/packages/bash-local/src/run.ts @@ -159,7 +159,11 @@ export class OutputCollector { writeSync(this.spillFd, chunk) } - /** Read the collected tail without finalizing (used by background polling). */ + // TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at + // the bottom of this file) and `totalBytes` is read only by a test. The live + // background-poll path goes through `readFrom()`, so inline snapshot() into + // finalize() and drop or privatize the totalBytes getter. + /** Read the collected tail without finalizing (the final-result snapshot). */ snapshot(): CollectedOutput { return { text: Buffer.concat(this.chunks).toString('utf8'), diff --git a/packages/tools/src/schema.ts b/packages/tools/src/schema.ts index 5e8887f11b..b717eabf9a 100644 --- a/packages/tools/src/schema.ts +++ b/packages/tools/src/schema.ts @@ -39,7 +39,14 @@ export interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** Default value. */ + /** + * Default value, emitted into the JSON Schema only (validation never applies + * it — see the validator note below). + * + * XXX(unused-default): no tool definition in the repo sets `default`; it rides + * into the wire schema for a model that no tool surfaces it to. Drop the field + * and its converter line unless a real tool needs a model-visible default. + */ default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec From 4e5c08ef82ec231f0acd394b89b50245bca8be43 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:47:09 +0800 Subject: [PATCH 38/87] docs: generated cordis events + services catalog Add scripts/gen-cordis-catalog.ts: a fully-generated docs/cordis-catalog/ events-and-services.md cataloging every cordis event (exact signature + @mode) and ctx. service (exact interface), modeled on gen-module-graph's --write/--check freshness gate. The harness tier renders in full from the interface Events / interface Context declarations and their JSDoc; the inherited cordis-core/loader/hmr/timer surface renders tersely from a curated table. The generator hard-errors on a missing @mode tag and on a tag that contradicts a conclusive signature shape (a trailing next param is structurally a waterfall). Signature blocks use a ts cordis-catalog fence that doc-typecheck skips. Type tokens cross-link to the core-data-structures catalog. This supersedes the hand-maintained event-taxonomy table: verify-event-taxonomy is deleted and verify-cordis-catalog joins doc-sync. architecture.md keeps the Event taxonomy heading (TOC anchor) but points at the catalog; the Service-map role table stays. RFC, AGENTS.md @mode authoring rule, and dependent doc/skill references updated. Negative gate tests cover the missing-tag and tag/shape-contradiction paths. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- .github/workflows/ci.yml | 9 +- AGENTS.md | 11 +- docs/architecture.md | 23 +- docs/cordis-catalog/events-and-services.md | 482 ++++++++++++++++++ docs/development.md | 7 +- docs/rfc/README.md | 1 + .../2026-06-11-doc-sync-enforcement.md | 2 +- .../2026-06-20-generated-cordis-catalog.md | 35 ++ package.json | 5 +- packages/AGENTS.md | 2 +- .../agent/tests/gen-cordis-catalog.spec.ts | 83 +++ scripts/doc-typecheck.ts | 35 +- scripts/gen-cordis-catalog.ts | 458 +++++++++++++++++ scripts/verify-event-taxonomy.ts | 114 ----- 15 files changed, 1107 insertions(+), 162 deletions(-) create mode 100644 docs/cordis-catalog/events-and-services.md create mode 100644 docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md create mode 100644 packages/agent/tests/gen-cordis-catalog.spec.ts create mode 100644 scripts/gen-cordis-catalog.ts delete mode 100644 scripts/verify-event-taxonomy.ts diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 0b3a28794f..d97bb1bd77 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -34,7 +34,7 @@ These come straight from the source docs above. They are not discretionary; abse 1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. 2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. 3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the event-taxonomy table, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b9a9fca4f..562ffca1bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,11 @@ jobs: # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the fenced ts blocks in # the docs and resolves vendor packages via their built declarations, which - # the typecheck step above emits — so it runs after typecheck. The event - # taxonomy check and the markdown wrap check only read source. Same - # `doc-sync` script the pre-push hook runs (quality-gates RFC: one source of truth). - - name: Doc-sync gates (doc code blocks + event taxonomy + markdown wrap) + # the typecheck step above emits — so it runs after typecheck. The cordis + # catalog freshness check, type-equiv check, and markdown wrap/link checks + # only read source. Same `doc-sync` script the pre-push hook runs + # (quality-gates RFC: one source of truth). + - name: Doc-sync gates (doc code blocks + cordis catalog + type-equiv + markdown wrap/links) run: pnpm run doc-sync # Module-graph freshness: regenerate docs/module-graph.md from the diff --git a/AGENTS.md b/AGENTS.md index 0ced918950..4d111ff672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,12 @@ pnpm run publint # package.json publish-correctness check (publishable pa pnpm run hygiene # knip + publint + workspace constraints pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, # packages/*/*.md (doc/code drift gate) -pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/architecture.md - # matches the interface Events declarations in source +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md + # (events + services) from the interface Events / Context source +pnpm run verify-cordis-catalog # assert that generated catalog is not stale pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -174,7 +175,9 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. + +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. The generated catalog is what supersedes the old hand-maintained event-taxonomy table. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/architecture.md b/docs/architecture.md index 799d543ded..e4b9014904 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,8 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. +For each service's full public interface (every method signature, generated from source), plus the inherited cordis-core/loader/hmr/timer surface a plugin also sees, see the `## Services` section of [cordis-catalog/events-and-services.md](cordis-catalog/events-and-services.md). This table is the at-a-glance role summary; that catalog is the exhaustive reference. + ## Capability seams: interface / implementation / consumer Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template: @@ -167,26 +169,7 @@ A failure that happens once the turn is already closed has no in-turn position f ### Event taxonomy -The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package); each other service declares its own events (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The table below is CI-verified against the `interface Events` declarations in source (`scripts/verify-event-taxonomy.ts`). - -| Event | Mode | Purpose | -|---|---|---| -| `agent/created` / `agent/disposed` / `agent/status` / `agent/queued` | emit | lifecycle + inbox notifications | -| `agent/turn-start` / `agent/turn-end` / `agent/step-start` / `agent/step-end` | emit | boundaries | -| `agent/request` | **waterfall** | mutate the final `GenerateOptions` before the model call | -| `agent/stream-chunk` | emit | token-level UI/log feed | -| `agent/step-result` | **waterfall** | post-process the assistant message before tool dispatch | -| `agent/steering` | emit | steering content injected | -| `agent/turn-continuation` | **waterfall** | override the continue/stop decision | -| `agent/error` | emit | step/turn errors | -| `tools/execute` (dsh-tools) | **waterfall** | wrap/veto/sandbox tool execution | -| `tools/change` (dsh-tools) | emit | a tool was registered/unregistered | -| `llm/stream` / `llm/generate` (dsh-llm) | **waterfall** | model-call interception | -| `llm/adapter-change` (dsh-llm) | emit | an adapter was registered/unregistered | -| `system-prompt/assemble` (dsh-system-prompt) | **waterfall** | mutate the assembly | -| `system-prompt/change` (dsh-system-prompt) | emit | a section/tool-provider changed | -| `session/created` / `session/event` (dsh-session) | emit | session lifecycle + log feed | -| `session/flush` (dsh-session) | parallel (awaited) | durability checkpoint | +The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package); each other service declares its own events (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The full catalog — every event's exact signature, dispatch mode, and prose — is **generated from source** and lives in [cordis-catalog/events-and-services.md](cordis-catalog/events-and-services.md) (the `## Events` section), alongside the `ctx.` service interfaces. That file is regenerated by `scripts/gen-cordis-catalog.ts` and frozen by the `verify-cordis-catalog` freshness gate (part of `doc-sync`), so it cannot drift from the `interface Events` declarations. ### Cordis waterfall semantics (important) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md new file mode 100644 index 0000000000..efbc51df46 --- /dev/null +++ b/docs/cordis-catalog/events-and-services.md @@ -0,0 +1,482 @@ + + +# Cordis Events & Services Catalog + +An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns. + +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. + +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer surface a plugin also sees — pinned vendor source, summarized tersely. + +## Events + +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 5 scopes. + +### `agent/*` + +#### `agent/created` — emit + +An agent was registered in the AgentRegistry and is ready to receive messages. + +```ts cordis-catalog +'agent/created'(agent: Agent): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:140`](../../packages/agent/src/types.ts) + +#### `agent/disposed` — emit + +An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down. + +```ts cordis-catalog +'agent/disposed'(agent: Agent): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:146`](../../packages/agent/src/types.ts) + +#### `agent/error` — 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. + +```ts cordis-catalog +'agent/error'(agent: Agent, turn: number, step: number, error: Error): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:223`](../../packages/agent/src/types.ts) + +#### `agent/queued` — emit + +A message entered the agent's inbox (queued or steering). `source` is the resolved source (defaults applied), not the caller's raw options. + +```ts cordis-catalog +'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void +``` + +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:159`](../../packages/agent/src/types.ts) + +#### `agent/request` — waterfall + +Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, compaction, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. + +```ts cordis-catalog +'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:192`](../../packages/agent/src/types.ts) + +#### `agent/status` — emit + +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. + +```ts cordis-catalog +'agent/status'(agent: Agent, status: AgentStatus): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:153`](../../packages/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/agent/src/types.ts:217`](../../packages/agent/src/types.ts) + +#### `agent/step-end` — emit + +A step ended. + +```ts cordis-catalog +'agent/step-end'(agent: Agent, turn: number, step: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:183`](../../packages/agent/src/types.ts) + +#### `agent/step-result` — waterfall + +Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). + +```ts cordis-catalog +'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:198`](../../packages/agent/src/types.ts) + +#### `agent/step-start` — emit + +A step (one model call plus its tool dispatch) began. `step` is 1-based within the turn; a turn runs one or more steps. + +```ts cordis-catalog +'agent/step-start'(agent: Agent, turn: number, step: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:178`](../../packages/agent/src/types.ts) + +#### `agent/stream-chunk` — emit + +A raw StreamChunk arrived from the model (token-level UI/log feed). + +```ts cordis-catalog +'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void +``` + +Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/agent/src/types.ts:212`](../../packages/agent/src/types.ts) + +#### `agent/turn-continuation` — waterfall + +Waterfall: override the turn-continuation decision. The default (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners can force-continue (/goal, /loop) or force-stop (budget guards). + +```ts cordis-catalog +'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:205`](../../packages/agent/src/types.ts) + +#### `agent/turn-end` — emit + +A turn ended. `reason` distinguishes a clean stop from a truncated or aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`). + +```ts cordis-catalog +'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void +``` + +Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) + +Source: [`packages/agent/src/types.ts:172`](../../packages/agent/src/types.ts) + +#### `agent/turn-start` — emit + +A turn began. `turn` is the 1-based turn number within the session. + +```ts cordis-catalog +'agent/turn-start'(agent: Agent, turn: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:166`](../../packages/agent/src/types.ts) + +### `llm/*` + +#### `llm/adapter-change` — emit + +An adapter was registered or unregistered (the model→adapter map changed). + +```ts cordis-catalog +'llm/adapter-change'(): void +``` + +Source: [`packages/llm/src/index.ts:43`](../../packages/llm/src/index.ts) + +#### `llm/generate` — waterfall + +Waterfall around every non-streaming model call. Bound to the LlmService; call `next()` to delegate to the adapter. + +```ts cordis-catalog +'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise +``` + +Types: [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) + +Source: [`packages/llm/src/index.ts:38`](../../packages/llm/src/index.ts) + +#### `llm/stream` — waterfall + +Waterfall around every streaming model call (retry, caching, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. + +```ts cordis-catalog +'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable +``` + +Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/llm/src/index.ts:32`](../../packages/llm/src/index.ts) + +### `session/*` + +#### `session/created` — emit + +A session was created in the store. + +```ts cordis-catalog +'session/created'(session: Session): void +``` + +Source: [`packages/session/src/index.ts:30`](../../packages/session/src/index.ts) + +#### `session/event` — emit + +An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. + +```ts cordis-catalog +'session/event'(session: Session, event: SessionEvent): void +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/session/src/index.ts:36`](../../packages/session/src/index.ts) + +#### `session/flush` — parallel + +Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flush', session)` at every turn end; persistence 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. + +```ts cordis-catalog +'session/flush'(session: Session): Promise | void +``` + +Source: [`packages/session/src/index.ts:45`](../../packages/session/src/index.ts) + +### `system-prompt/*` + +#### `system-prompt/assemble` — waterfall + +Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tool schemas) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. + +```ts cordis-catalog +'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise +``` + +Source: [`packages/system-prompt/src/index.ts:24`](../../packages/system-prompt/src/index.ts) + +#### `system-prompt/change` — emit + +A section or tool provider was registered or unregistered (the assembly inputs changed). + +```ts cordis-catalog +'system-prompt/change'(): void +``` + +Source: [`packages/system-prompt/src/index.ts:30`](../../packages/system-prompt/src/index.ts) + +### `tools/*` + +#### `tools/change` — emit + +A tool was registered or unregistered (the available tool set changed). + +```ts cordis-catalog +'tools/change'(): void +``` + +Source: [`packages/tools/src/index.ts:48`](../../packages/tools/src/index.ts) + +#### `tools/execute` — waterfall + +Waterfall around every tool execution — the single seam where sandbox, permission, hook, and plan-mode plugins wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed (possibly around your own logic), or return a ToolExecutionResult without calling `next()` to short-circuit (veto). + +```ts cordis-catalog +'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +``` + +Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/tools/src/index.ts:43`](../../packages/tools/src/index.ts) + +## Services + +The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. + +### `ctx.agentLoop` — `AgentLoop` + +The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`. Also implements the AgentFactory seam, so plugins create/resume agents through `ctx.agents` (the interface) without depending on this concrete package. + +The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. + +```ts cordis-catalog +create(id: string, options: AgentOptions = {}): ReactLoopAgent +createAgent(options: CreateAgentOptions): AgentHandle +async resume(options: ResumeAgentOptions): Promise +``` + +Source: [`packages/agent-loop/src/index.ts:60`](../../packages/agent-loop/src/index.ts) + +### `ctx.agents` — `AgentRegistry` + +Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory. + +```ts cordis-catalog +setFactory(factory: AgentFactory): () => void +create(options: CreateAgentOptions): AgentHandle +async resume(options: ResumeAgentOptions): Promise +register(agent: Agent): () => void +get(id: string): Agent | undefined +list(): Agent[] +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/index.ts:105`](../../packages/agent/src/index.ts) + +### `ctx.bash` — `BashExecutor` (abstract seam) + +Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Semantics every implementation must honor: + +- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception. +- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed. +- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available. +- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`). + +```ts cordis-catalog +abstract resolve(request: BashExecRequest): BashExecSpec +abstract run(spec: BashExecSpec): Promise +abstract start(spec: BashExecSpec): BashTask +abstract get(id: string): BashTask | undefined +abstract ownerOf(id: string): string | undefined +abstract list(): BashTask[] +abstract readOutput(id: string): BashTaskRead +abstract kill(id: string): boolean +onTaskDone(listener: BashTaskListener): () => void +protected notifyTaskDone(task: BashTask): void +``` + +Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) + +Source: [`packages/bash/src/index.ts:58`](../../packages/bash/src/index.ts) + +### `ctx.llm` — `LlmService` + +The abstract `llm` service: an adapter registry plus streaming / non-streaming call surfaces, both interceptable via waterfall events. + +```ts cordis-catalog +registerAdapter(models: string[], adapter: LlmAdapter): () => void +models(): string[] +stream(options: GenerateOptions): AsyncIterable +async * streamBlocks(options: GenerateOptions): AsyncIterable +generate(options: GenerateOptions): Promise +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/llm/src/index.ts:81`](../../packages/llm/src/index.ts) + +### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) + +Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). + +Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF): + +- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. +- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). +- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. +- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). + +```ts cordis-catalog +abstract create(meta: SessionHeader): Promise +abstract append(id: SessionId, events: readonly SessionEvent[]): Promise +abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract list(): Promise +abstract has(id: SessionId): Promise +abstract delete(id: SessionId): Promise +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/session-persistence/src/index.ts:98`](../../packages/session-persistence/src/index.ts) + +### `ctx.sessions` — `SessionStore` + +In-memory session store (`ctx.sessions`). + +Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. + +```ts cordis-catalog +create(id?: string, options?: CreateSessionOptions): Session +prepare(id?: string, options?: CreateSessionOptions): Session +enter(session: Session): () => void +announce(session: Session): void +get(id: string): Session | undefined +list(): Session[] +``` + +Source: [`packages/session/src/index.ts:222`](../../packages/session/src/index.ts) + +### `ctx.systemPrompt` — `SystemPrompt` + +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. + +```ts cordis-catalog +section(section: PromptSection): () => void +tools(provider: () => ToolSchema[]): () => void +assemble(): Promise +``` + +Source: [`packages/system-prompt/src/index.ts:71`](../../packages/system-prompt/src/index.ts) + +### `ctx.tools` — `ToolRegistry` + +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/execute` waterfall. The registry contributes its schemas into the system-prompt assembly. + +```ts cordis-catalog +register(definition: ToolDefinition): () => void +get(name: string): ToolDefinition | undefined +schemas(): ToolSchema[] +async execute(exec: ToolExecution): Promise +``` + +Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/tools/src/index.ts:277`](../../packages/tools/src/index.ts) + +## Inherited tier (cordis core + loader/hmr/timer) + +The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier's prominence. + +### Inherited events + +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:197`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:198`](../../vendor/cordis/src/events.ts)) +- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:199`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:200`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:201`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:202`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:203`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:204`](../../vendor/cordis/src/events.ts)) +- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) +- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) +- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) +- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) +- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) +- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) +- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) + +### Inherited `ctx` members + +- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-non-nullish / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts)) +- `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts)) +- `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts)) +- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:35`](../../vendor/cordis/src/context.ts)) +- `ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts)) +- `ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)` — Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts)) +- `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts)) +- `ctx.hmr` — The hot-module-reload watcher (present under the hmr plugin). ([`vendor/hmr/src/index.ts:15`](../../vendor/hmr/src/index.ts)) diff --git a/docs/development.md b/docs/development.md index d5796d32a8..3199893c3d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,17 +93,18 @@ pnpm run typecheck # build declarations, then typecheck source, tests, and pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs -pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md from source +pnpm run verify-cordis-catalog # fail if the cordis events/services catalog is stale pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type -pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap/link, and type-equiv verification +pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap/link, and type-equiv verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # build declarations and JS bundles pnpm run hygiene # knip, publint, and workspace constraints ``` -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, event-taxonomy drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. +When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, cordis events/services catalog drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. ## Demos diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 0fe36dca75..5b53345345 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -62,6 +62,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | | [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | +| [Generated cordis events + services catalog](implemented/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md index 68e6f0a03d..9abc7c08b0 100644 --- a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md @@ -13,7 +13,7 @@ AGENTS.md promises that docs and code stay strictly in sync, but the promise was Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): 1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project, and compiles with `tsc --noEmit`. The temp tsconfig copies only resolution-relevant options and the workspace `paths` map from `tsconfig.typecheck.json` (vendor → built `lib`, harness → `src`) — resolving vendor to `lib` is essential, or tsc type-checks raw vendor source and floods the run. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. -2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) +2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of a fully-generated `docs/cordis-catalog/events-and-services.md` and its `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected. Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck` (which emits the vendor `lib/` that doc-typecheck resolves against). API-extractor golden reports ([the deferred API-extractor-reports proposal](../proposed/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. diff --git a/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md new file mode 100644 index 0000000000..4f2ba01fcb --- /dev/null +++ b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md @@ -0,0 +1,35 @@ +# RFC: Generated cordis events + services catalog + +Status: implemented (accepted 2026-06-20) + + + +## Context + +A plugin author needs two reference surfaces that no single document gave them: every cordis **event** they can listen to (with its exact signature and dispatch mode) and every `ctx.` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides. + +This is the wiring-axis complement to the [core-data-structures catalog](../../core-data-structures/core.md): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. + +## Decision + +Generate the catalog from source instead of hand-maintaining a table and verifying a subset. + +`scripts/gen-cordis-catalog.ts` walks the `interface Events` and `interface Context` declarations (plus the service classes) with the TypeScript compiler API and emits `docs/cordis-catalog/events-and-services.md` — one `## Events` section (grouped by scope, each event rendered as signature + mode badge + its source JSDoc) and one `## Services` section (each `ctx.` with its public method signatures + class JSDoc). It mirrors the `gen-module-graph` pattern exactly: `--write` regenerates, `--check` fails if the committed file is stale, output is deterministic (sorted), and the file is a build artifact that is never hand-edited. `verify-cordis-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. + +Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). + +Specific choices: + +- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../AGENTS.md). +- **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. +- **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. +- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. + +This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). The verify-don't-generate principle that RFC chose for the taxonomy is reversed *for this surface only* — the data here is mechanically complete, so generation is strictly stronger (full signatures, cannot drift, catches undocumented events) than a name-set check of a hand-table. doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged. + +## Consequences + +- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, or a tag that contradicts its signature, fails the generator outright. +- Event prose now has a single home — the JSDoc at the declaration. Thin JSDoc yields a thin catalog entry, which pressures authors to document at the source (the generator is a forcing function for the AGENTS.md "every export has a semantic JSDoc" rule). +- The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator. +- `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead. diff --git a/package.json b/package.json index e171048edf..eeb5dd7d07 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,15 @@ "knip": "knip", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", - "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ae7c7d5a17..735e23a156 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -13,6 +13,6 @@ Naming notes: - A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above) - `src/types.ts` contain only types — no runtime code - Tests live at package level under `tests/`, not `src/__tests__/` -- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, verifies the event-taxonomy table, and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. +- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. diff --git a/packages/agent/tests/gen-cordis-catalog.spec.ts b/packages/agent/tests/gen-cordis-catalog.spec.ts new file mode 100644 index 0000000000..66edb9983f --- /dev/null +++ b/packages/agent/tests/gen-cordis-catalog.spec.ts @@ -0,0 +1,83 @@ +/** + * Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`). + * + * 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. + */ + +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' + +/** Write a fixture package exposing one `interface Events` block and return the + * scan root to hand `collectEvents`. */ +function fixtureRoot(eventsBlock: string): string { + const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) + const dir = join(root, 'packages', 'fix', 'src') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'index.ts'), + `declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, + ) + return root +} + +const roots: string[] = [] +const make = (block: string): string => { + const r = fixtureRoot(block) + roots.push(r) + return r +} + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) +}) + +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', + )) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) + }) + + 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): Promise', + )) + expect(events[0]?.mode).toBe('waterfall') + }) + + it('accepts a parallel (awaited, no next) event by trusting the tag', () => { + const events = collectEvents(make( + ' /**\n * Flush.\n * @mode parallel\n */\n \'fix/flush\'(): Promise | void', + )) + expect(events[0]?.mode).toBe('parallel') + }) + + it('hard-errors when an event is missing its @mode tag', () => { + expect(() => collectEvents(make( + ' /** No mode here. */\n \'fix/untagged\'(id: string): 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): Promise', + ))).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', + ))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/) + }) +}) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 80bdc6cc75..905adfb630 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -9,10 +9,13 @@ * compilable code opts out with an explicit ` ```ts ignore-check ` info string * — the opt-out is visible in the source, and this script reports the ratio so * the escape hatch can't quietly become the norm. A third info string, - * ` ```ts type-equiv `, marks a verbatim paste of a source type definition that - * `scripts/verify-type-equiv.ts` drift-checks against the source symbol; it is - * skipped here and EXCLUDED from the opt-out ratio (a separately-checked - * category, not an unchecked sketch). + * doc-typecheck.ts recognizes two more fence variants and skips both (each is a + * separately-checked category, not an unchecked sketch, so neither counts in the + * opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that + * `scripts/verify-type-equiv.ts` drift-checks, and ` ```ts cordis-catalog ` is a + * generated event/service signature fragment in the cordis catalog (a bare + * signature is not standalone-compilable; the catalog is generated and frozen by + * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate). * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -34,8 +37,13 @@ const root = resolve(import.meta.dirname, '..') * source symbol. Skipped HERE (it is not standalone-compilable — no imports) * and EXCLUDED from the opt-out ratio: it is a separate fully-checked * category, not an unchecked sketch. + * - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service + * signature fragment in the cordis catalog. Skipped HERE for the same reason + * (a bare signature fragment has no imports and does not stand alone) and + * EXCLUDED from the opt-out ratio: the catalog is generated and frozen by + * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate. */ -type BlockKind = 'check' | 'ignore' | 'type-equiv' +type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' /** One extracted code block. */ interface Block { @@ -46,7 +54,7 @@ interface Block { code: string } -/** Extract every ```ts / ```ts ignore-check / ```ts type-equiv block from one Markdown file. */ +/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') const lines = text.split('\n') @@ -72,7 +80,8 @@ function extractBlocks(absPath: string): Block[] { info === 'ts' ? 'check' : info === 'ts ignore-check' ? 'ignore' : info === 'ts type-equiv' ? 'type-equiv' - : null + : info === 'ts cordis-catalog' ? 'cordis-catalog' + : null if (kind) open = { line: i + 1, kind, body: [] } }) return blocks @@ -127,10 +136,11 @@ files.sort() const all = files.flatMap(extractBlocks) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') -// `type-equiv` blocks are verified by verify-type-equiv.ts, not here: neither -// compiled nor counted toward the opt-out ratio (they are a separate -// fully-checked category, not an unchecked sketch). The ratio's denominator is -// therefore the compile-eligible blocks only. +// `type-equiv` and `cordis-catalog` blocks are verified elsewhere +// (verify-type-equiv.ts and the gen-cordis-catalog `--check` freshness gate), +// not here: neither compiled nor counted toward the opt-out ratio (each is a +// separate fully-checked category, not an unchecked sketch). The ratio's +// denominator is therefore the compile-eligible blocks only. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { @@ -164,7 +174,8 @@ try { } const ratio = ignored.length / ratioDenominator - console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${all.length - ratioDenominator} type-equiv (checked by verify-type-equiv).`) + const skipped = all.length - ratioDenominator + console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/cordis-catalog (checked elsewhere).`) // Guard against the escape hatch becoming the norm. if (ratioDenominator >= 4 && ratio > 0.5) { console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts new file mode 100644 index 0000000000..e0eac52d16 --- /dev/null +++ b/scripts/gen-cordis-catalog.ts @@ -0,0 +1,458 @@ +/** + * Generate (and verify) the cordis events + services catalog in + * docs/cordis-catalog/events-and-services.md. + * + * The catalog is the WIRING-axis reference: every cordis event a plugin can + * listen to (exact signature + dispatch mode) and every `ctx.` service it + * can call (exact public interface). It complements the core-data-structures + * catalog (the VOCABULARY axis — the types these signatures move around). + * + * Unlike the core-data-structures docs (a hand-paste drift-checked by + * verify-type-equiv), this file is FULLY GENERATED from source — never + * hand-edit it. The codebase is disciplined enough that a pure-AST pass + * captures the whole truthful surface: every event/service is a string literal + * that round-trips to a static `interface Events` / `interface Context` + * declaration (no dynamically-named events, no runtime-only services). So the + * committed file is a build artifact and a regenerate-and-diff freshness check + * (`--check`) makes drift structurally impossible — which also closes the gap a + * name-set verifier could not: a brand-new UNDOCUMENTED event cannot slip + * through, because generation enumerates source rather than checking a subset. + * + * `tsx scripts/gen-cordis-catalog.ts` → write the catalog + * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + * + * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in + * full from source: signature, the `@mode` badge, and the declaration's JSDoc. + * Every harness event MUST carry an `@mode emit|waterfall|parallel` 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 + * 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. + * + * Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck + * recognizes it and skips compilation (the signatures are fragments, not + * standalone-compilable, like the ` ```ts type-equiv ` blocks). + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/cordis-catalog/events-and-services.md' + +/** The fenced-block info string for generated signature blocks (skipped by + * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ +const FENCE = 'ts cordis-catalog' + +/** A dispatch mode, rendered as the badge after an event name. */ +type Mode = 'emit' | 'waterfall' | 'parallel' + +/** + * Cross-link map: a type name that appears in a signature → the + * core-data-structures page that documents it (path relative to OUT's folder). + * Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json — + * that manifest documents the `…Map` symbols (`ContentBlockMap`) while + * signatures reference the derived UNION names (`ContentBlock`), and it lists a + * few symbols on two pages. Here each name resolves to exactly one PRIMARY page. + */ +const LINK_MAP: Record = { + Agent: 'core.md', + ContentBlock: 'core.md', + Message: 'core.md', + MessageSource: 'core.md', + GenerateOptions: 'core.md', + GenerateResult: 'core.md', + SessionEvent: 'core.md', + StreamChunk: 'llm-streaming.md', + TurnEndReason: 'session.md', + ToolDefinition: 'tools.md', + ToolExecution: 'tools.md', + ToolExecutionResult: 'tools.md', + BashExecRequest: 'bash.md', + BashExecSpec: 'bash.md', + BashRunResult: 'bash.md', + BashTask: 'bash.md', +} + +/** One harness event, extracted from an `interface Events` block. */ +interface EventEntry { + /** Scoped name, e.g. `agent/request`. */ + name: string + /** The scope prefix, e.g. `agent` (everything before the first `/`). */ + scope: string + /** Full signature text (the method-signature member, JSDoc stripped). */ + signature: string + /** Dispatch mode from the `@mode` tag. */ + mode: Mode + /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ + doc: string + /** Source pointer `packages/…/file.ts:line` of the declaration. */ + source: string +} + +/** One harness service, extracted from an `interface Context` block. */ +interface ServiceEntry { + /** The `ctx.` name, e.g. `llm`. */ + key: string + /** The service class/interface name, e.g. `LlmService`. */ + type: string + /** Whether the service class is abstract (a seam interface). */ + abstract: boolean + /** Class-level JSDoc prose, one line per paragraph. */ + doc: string + /** Public method signatures (bodies stripped), in source order. */ + methods: string[] + /** Source pointer of the class declaration. */ + source: string +} + +/** A terse inherited-tier entry (pinned vendor surface). */ +interface InheritedEntry { + name: string + summary: string + /** Source pointer `vendor/…:line`. */ + source: string +} + +/** Repo-relative source pointer `file:line` for a node's first character. */ +function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + return `${rel}:${line + 1}` +} + +/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */ +function rawJsDoc(text: string, node: ts.Node): string { + const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [] + const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1) + return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' +} + +/** + * Parse a raw JSDoc block into description prose + the `@mode` tag (when + * 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. + */ +function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + let mode: Mode | null = null + const blocks: string[] = [] + let para: string[] = [] + let list: string[] = [] + let item: string[] = [] + const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim() + const flushItem = (): void => { + if (item.length) list.push(join(item)) + item = [] + } + const flushList = (): void => { + flushItem() + if (list.length) blocks.push(list.join('\n')) // one block, items on own lines + list = [] + } + const flushPara = (): void => { + flushList() + if (para.length) blocks.push(join(para)) + para = [] + } + for (const line of inner) { + const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line) + if (m) { mode = m[1] as Mode; continue } + if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose + if (line.trim() === '') { flushPara(); continue } + if (/^-\s+/.test(line)) { + // A list item starts: a pending paragraph (e.g. an intro line directly + // above the list, no blank between) flushes FIRST so it renders above. + flushItem() + if (para.length) { blocks.push(join(para)); para = [] } + item.push(line) + continue + } + if (item.length) { item.push(line); continue } // continuation of current item + para.push(line) + } + flushPara() + const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim() + return { doc, mode } +} + +/** 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) { + if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') { + if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body + } + } + return null +} + +/** The signature text of a method-signature member (everything but a body). */ +function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string { + const full = member.getText(sf) + const body = (member as { body?: ts.Node }).body + const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full + 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. */ +export function collectEvents(scanRoot: string = root): EventEntry[] { + const entries: EventEntry[] = [] + for (const rel of globSync('packages/*/src/*.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('interface Events')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + const body = cordisModuleBody(sf) + if (!body) continue + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue + for (const member of stmt.members) { + 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 src = pointer(rel, sf, member) + if (!mode) { + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`) + } + // Conclusive structural check: a trailing `next: () => …` parameter is a + // waterfall. (emit vs parallel 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 (!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().`) + } + entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + } + } + } + return entries +} + +/** Walk every harness `interface Context` block + its service class. + * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +export function collectServices(scanRoot: string = root): ServiceEntry[] { + const entries: ServiceEntry[] = [] + for (const rel of globSync('packages/*/src/index.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('interface Context')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + const body = cordisModuleBody(sf) + if (!body) continue + // The ctx key → type mapping(s) declared in this file's interface Context. + const keyToType = new Map() + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue + for (const member of stmt.members) { + if (!ts.isPropertySignature(member) || !member.type) continue + const key = member.name.getText(sf) + keyToType.set(key, member.type.getText(sf)) + } + } + if (keyToType.size === 0) continue + // Find each service class declared in the same file and emit an entry. + for (const [key, type] of keyToType) { + const cls = sf.statements.find( + (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, + ) + 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 methods: string[] = [] + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue + const isPrivate = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) + || ts.isPrivateIdentifier(member.name) + const isStatic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) + if (isPrivate || isStatic) continue + const memberName = member.name.getText(sf) + if (memberName.startsWith('[')) continue // computed/symbol members + methods.push(memberSignature(member, sf)) + } + entries.push({ + key, + type, + abstract, + doc: parseJsDoc(rawJsDoc(text, cls)).doc, + methods, + source: pointer(rel, sf, cls), + }) + } + } + return entries.sort((a, b) => a.key.localeCompare(b.key)) +} + +/** + * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and + * hand-summarized because (a) it is pinned vendor source that changes only on a + * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members + * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would + * wrongly surface as services, and (c) the internal/* events carry no JSDoc to + * render. Source pointers are verified against vendor by `verify-md-links`' + * sibling check is N/A; keep them current on a vendor bump. + */ +const INHERITED_EVENTS: InheritedEntry[] = [ + { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' }, + { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' }, + { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' }, + { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' }, + { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' }, + { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' }, + { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' }, + { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' }, + { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, + { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, + { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, + { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' }, + { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' }, +] + +const INHERITED_SERVICES: InheritedEntry[] = [ + { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, + { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, + { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, + { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' }, + { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' }, + { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' }, + { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' }, + { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' }, +] + +/** Render the cross-link "Types:" line for a signature, or '' if none apply. */ +function typeLinks(signature: string): string { + const seen = new Set() + for (const name of Object.keys(LINK_MAP)) { + if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name) + } + if (seen.size === 0) return '' + const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`) + return `Types: ${links.join(' · ')}` +} + +/** Render one harness event entry. */ +function renderEvent(e: EventEntry): string[] { + const out = [`#### \`${e.name}\` — ${e.mode}`, ''] + if (e.doc) out.push(e.doc, '') + out.push('```' + FENCE, e.signature, '```', '') + const links = typeLinks(e.signature) + if (links) out.push(links, '') + out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') + return out +} + +/** Render one harness service entry. */ +function renderService(s: ServiceEntry): string[] { + const kind = s.abstract ? ' (abstract seam)' : '' + const out = [`### \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] + if (s.doc) out.push(s.doc, '') + if (s.methods.length) { + out.push('```' + FENCE, ...s.methods, '```', '') + const links = typeLinks(s.methods.join('\n')) + if (links) out.push(links, '') + } + out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') + return out +} + +/** Render the full catalog (pure, deterministic given sorted inputs). */ +function render(events: EventEntry[], services: ServiceEntry[]): string { + const lines: string[] = [ + '', + '', + '# Cordis Events & Services Catalog', + '', + 'An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns.', + '', + 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.', + '', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer surface a plugin also sees — pinned vendor source, summarized tersely.', + '', + '## Events', + '', + `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, + '', + ] + const scopes = [...new Set(events.map(e => e.scope))].sort() + for (const scope of scopes) { + lines.push(`### \`${scope}/*\``, '') + for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(...renderEvent(e)) + } + } + lines.push( + '## Services', + '', + `The ${services.length} \`ctx.\` services the harness provides. An abstract seam (e.g. \`ctx.bash\`) is implemented by a separate package; the interface is what consumers code against.`, + '', + ) + for (const s of services) lines.push(...renderService(s)) + lines.push( + '## Inherited tier (cordis core + loader/hmr/timer)', + '', + 'The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier\'s prominence.', + '', + '### Inherited events', + '', + ) + for (const e of INHERITED_EVENTS) { + lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`) + } + lines.push('', '### Inherited `ctx` members', '') + for (const s of INHERITED_SERVICES) { + lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`) + } + lines.push('') + return lines.join('\n') +} + +/** CLI entry: `--write` (default) writes the catalog, `--check` fails if stale. + * Guarded behind an entry-point check so importing this module for tests neither + * regenerates the committed file nor calls process.exit. */ +function main(): void { + const content = render(collectEvents(), collectServices()) + if (process.argv.includes('--check')) { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + if (committed === content) { + console.log(`gen-cordis-catalog: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-cordis-catalog: wrote ${OUT}.`) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} diff --git a/scripts/verify-event-taxonomy.ts b/scripts/verify-event-taxonomy.ts deleted file mode 100644 index f133d9cf46..0000000000 --- a/scripts/verify-event-taxonomy.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Doc-sync gate (doc-sync-enforcement RFC, part 2): verify the event-taxonomy table in - * docs/architecture.md against the events actually declared in source. - * - * The table duplicates the `declare module 'cordis' { interface Events }` - * blocks across packages/* /src. This script extracts both sets of event names - * and asserts they match exactly — every declared event appears in the table, - * and the table names no event that isn't declared. Verify, don't generate - * (per the RFC): the table keeps its hand-written Mode/Purpose columns; only - * the set of names is checked. - * - * Run: `tsx scripts/verify-event-taxonomy.ts`. - */ - -import { readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' -import { glob } from 'node:fs/promises' - -const root = resolve(import.meta.dirname, '..') - -/** - * Remove `/* *​/` block comments and `//` line comments from TS source. Used to - * de-risk the brace walk in {@link declaredEvents} — a JSDoc `{@link}` tag would - * otherwise throw off the `{`/`}` depth counter. Good enough for our own source - * (no string literals contain `//` or comment-like brace sequences in an Events - * block); it is not a general tokenizer. - */ -function stripComments(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/.*$/gm, '$1') -} - -/** - * Event names declared in source: the keys inside every `interface Events` - * block under packages/* /src. A declared event is a quoted `'scope/name'(` - * method signature at the start of a line within such a block. - */ -async function declaredEvents(): Promise> { - const found = new Map() - for await (const match of glob('packages/*/src/**/*.ts', { cwd: root })) { - const abs = resolve(root, match) - // Strip comments first so a JSDoc `{@link …}` tag (or a `// {` line) inside - // an Events block can't unbalance the brace walk below. Event names live in - // code, never in comments, so this loses nothing. - const text = stripComments(readFileSync(abs, 'utf8')) - // Walk `interface Events {` blocks brace-balanced and pull quoted keys. - const re = /interface\s+Events\s*\{/g - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - let depth = 1 - let i = m.index + m[0].length - const start = i - while (i < text.length && depth > 0) { - const ch = text[i] - if (ch === '{') depth++ - else if (ch === '}') depth-- - i++ - } - const body = text.slice(start, i - 1) - // A declaration is a quoted event name followed by `(` (method form). - for (const k of body.matchAll(/['"]([a-z][a-z-]*\/[a-z-]+)['"]\s*\(/g)) { - const name = k[1] - if (name) found.set(name, relative(root, abs)) - } - } - } - return found -} - -/** Event names referenced in the architecture-doc taxonomy table (in `code`). */ -function tableEvents(): Set { - const text = readFileSync(join(root, 'docs/architecture.md'), 'utf8') - const lines = text.split('\n') - const heading = lines.findIndex(l => /^###\s+Event taxonomy/.test(l)) - if (heading === -1) throw new Error('verify-event-taxonomy: "### Event taxonomy" heading not found') - const names = new Set() - for (let i = heading + 1; i < lines.length; i++) { - const line = lines[i] ?? '' - if (/^###\s/.test(line)) break // next section ends the table - if (!line.includes('|')) continue - for (const code of line.matchAll(/`([^`]+)`/g)) { - // A cell may read "`a/b` / `c/d` (pkg)" — pull each scoped name. - for (const name of (code[1] ?? '').matchAll(/[a-z][a-z-]*\/[a-z-]+/g)) names.add(name[0]) - } - } - return names -} - -const declared = await declaredEvents() -const table = tableEvents() - -const declaredNames = new Set(declared.keys()) -const missingFromTable = [...declaredNames].filter(n => !table.has(n)).sort() -const missingFromSource = [...table].filter(n => !declaredNames.has(n)).sort() - -if (missingFromTable.length === 0 && missingFromSource.length === 0) { - console.log(`verify-event-taxonomy: ${declaredNames.size} events match the architecture-doc table.`) - process.exit(0) -} - -if (missingFromTable.length > 0) { - console.error('verify-event-taxonomy: declared in source but MISSING from the docs/architecture.md table:') - for (const n of missingFromTable) { - console.error(` ${n} (declared in ${declared.get(n) ?? '?'})`) - } -} -if (missingFromSource.length > 0) { - console.error('verify-event-taxonomy: named in the table but NOT declared in source (stale doc):') - for (const n of missingFromSource) { - console.error(` ${n}`) - } -} -process.exit(1) From d324b06e7403b64e5f6b649a3d2cb40265a32a9e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:04:39 +0800 Subject: [PATCH 39/87] docs: fix Codex review findings on the cordis catalog - Exclude protected methods from the generated service interface: a protected member (e.g. BashExecutor.notifyTaskDone) is a subclass hook, not part of the public ctx. surface a plugin author calls. The method filter now drops private, protected, and static. - Add BashTaskRead to the type cross-link map so readOutput()'s return type links to its core-data-structures page. - Reword the generator module comment and the AGENTS.md @mode rule to state the current capability without narrating the retired event-taxonomy verifier (that history lives in the RFC). --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 3 +- scripts/gen-cordis-catalog.ts | 32 +++++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4d111ff672..8b8319ade0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `p Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. -**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. The generated catalog is what supersedes the old hand-maintained event-taxonomy table. +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index efbc51df46..e34a2321d1 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -355,10 +355,9 @@ abstract list(): BashTask[] abstract readOutput(id: string): BashTaskRead abstract kill(id: string): boolean onTaskDone(listener: BashTaskListener): () => void -protected notifyTaskDone(task: BashTask): void ``` -Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) +Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) Source: [`packages/bash/src/index.ts:58`](../../packages/bash/src/index.ts) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e0eac52d16..49c451bc20 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -7,16 +7,15 @@ * can call (exact public interface). It complements the core-data-structures * catalog (the VOCABULARY axis — the types these signatures move around). * - * Unlike the core-data-structures docs (a hand-paste drift-checked by - * verify-type-equiv), this file is FULLY GENERATED from source — never - * hand-edit it. The codebase is disciplined enough that a pure-AST pass - * captures the whole truthful surface: every event/service is a string literal - * that round-trips to a static `interface Events` / `interface Context` - * declaration (no dynamically-named events, no runtime-only services). So the - * committed file is a build artifact and a regenerate-and-diff freshness check - * (`--check`) makes drift structurally impossible — which also closes the gap a - * name-set verifier could not: a brand-new UNDOCUMENTED event cannot slip - * through, because generation enumerates source rather than checking a subset. + * The catalog is FULLY GENERATED from source — never hand-edit it. The codebase + * is disciplined enough that a pure-AST pass captures the whole truthful + * surface: every event/service is a string literal that round-trips to a static + * `interface Events` / `interface Context` declaration (no dynamically-named + * events, no runtime-only services). So the committed file is a build artifact + * and a regenerate-and-diff freshness check (`--check`) makes drift structurally + * impossible. Because generation enumerates source rather than checking a + * hand-written subset, a brand-new event cannot be silently undocumented — it + * appears in the next regenerate, and an un-regenerated file fails `--check`. * * `tsx scripts/gen-cordis-catalog.ts` → write the catalog * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file @@ -76,6 +75,7 @@ const LINK_MAP: Record = { BashExecSpec: 'bash.md', BashRunResult: 'bash.md', BashTask: 'bash.md', + BashTaskRead: 'bash.md', } /** One harness event, extracted from an `interface Events` block. */ @@ -276,10 +276,16 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue - const isPrivate = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) + // Only the PUBLIC callable surface a `ctx.` consumer sees. Drop + // private/protected (a protected method like `notifyTaskDone` is a + // subclass hook, not something a plugin calls through `ctx.bash`) and + // static (not reachable through the instance). + const nonPublic = member.modifiers?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword + || m.kind === ts.SyntaxKind.ProtectedKeyword + || m.kind === ts.SyntaxKind.StaticKeyword) || ts.isPrivateIdentifier(member.name) - const isStatic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) - if (isPrivate || isStatic) continue + if (nonPublic) continue const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members methods.push(memberSignature(member, sf)) From 99a203f445f4db36f97e69d1a7b00f8bfc497415 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:09:26 +0800 Subject: [PATCH 40/87] docs: add simplification discovery skill --- .../skills/dsh-find-simplifications/SKILL.md | 106 ++++++++++++++++++ .../agents/openai.yaml | 4 + 2 files changed, 110 insertions(+) create mode 100644 .agents/skills/dsh-find-simplifications/SKILL.md create mode 100644 .agents/skills/dsh-find-simplifications/agents/openai.yaml diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md new file mode 100644 index 0000000000..ac8565e210 --- /dev/null +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -0,0 +1,106 @@ +--- +name: dsh-find-simplifications +description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates and write proposed RFCs or inline TODO/FIXME/XXX notes for dead, duplicated, speculative, or over-built code surfaces; especially for requests like "find simplification RFCs", "look for unnecessary complexity", "audit for removal-style cleanups", or "fold worthwhile simplification ideas from another PR".' +--- + +# Finding DeepSeek Harness Simplifications + +This skill helps turn a broad "find things to simplify" request into evidence-backed RFCs that remove or collapse existing harness surface area. It is guidance, not a checklist: follow the code, keep judgment active, and prefer a few well-proven candidates over a pile of thin guesses. + +## Start With Repo Context + +- Read `AGENTS.md`, especially the pre-release stance, tests-document-behavior section, conventions, defensive patterns, and Type Safety and Documentation section. +- Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. +- Use the RFC index ([docs/rfc/README.md](../../../docs/rfc/README.md)) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../../docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../../docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../../docs/rfc/implemented/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend RFCs. +- Treat dual LLM adapters and dual persistence backends as intentional by default. Do not propose deleting either twin/backend as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. + +## What Counts As A Strong Candidate + +A strong simplification removes, folds, or demotes something real and has clear evidence that the current shape costs more than it buys: + +- A public method, event, config knob, registry notification, helper, package, durable event, or test artifact has no production consumer. +- Tests or docs are the only consumers, and the behavior they pin is not load-bearing. +- Two representations mirror the same fact, especially across durable session events and transient `agent/*` events. +- A seam has methods every implementation must support but no consumer uses. +- A package boundary exists only for test/demo/support code and adds publish or dependency overhead. +- A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar shapes with no product owner. +- An invariant, rollback path, goldens set, or special-case test exists only to protect an unused surface. +- The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. + +Thin candidates are usually not enough for an RFC: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. + +## Survey Broadly + +Use parallel subagents when the user asks for breadth or many candidates. Give each agent a domain and require evidence, not guesses. Useful domains: + +- Agent loop and session log: turn/step boundaries, steering, abort/cancel, durable events, replay, load/resume. +- ACP and UI surfaces: `session/*` methods, terminal `_meta`, transcript rendering, single vs multi-session state. +- LLM/tools/system prompt: stream/generate surfaces, assemblers, registries, tool schema defaults, presentation hooks. +- Bash and tool execution: foreground/background split, task ownership, output spill files, executor methods. +- Packages/examples/scripts/tests: package boundaries, static inventories, redundant snapshot goldens, support packages. + +If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. + +## Prove Or Reject Each Candidate + +For every symbol or behavior, classify consumers before writing: + +- Production corpus: `packages/*/src`, `examples/*/src`, `examples/**/*.yml`, runtime scripts, and loader/config paths. +- Non-production corpus: tests, README/docs, RFCs, snapshots, generated goldens, and comments. +- Ambiguous corpus: examples and scripts that may be product smoke paths. Inspect usage before classifying. + +Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites. `knip` can help, but it is not a substitute for understanding public interfaces, dynamic event names, tests, docs, and Cordis loader paths. + +Reject or downgrade a candidate when: + +- A production caller exists and the simplification would be a feature decision rather than a cleanup. +- The surface is explicitly justified by an implemented RFC or a hard-won defensive pattern, and the new evidence does not beat that reason. +- The removal would force unrelated churn without actually making the contract smaller. +- The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). + +## Write The RFC + +Create one file per durable proposal under `docs/rfc/proposed/yyyy-mm-dd-topic.md` and add it to the Proposed table in `docs/rfc/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. + +Prefer this shape, adjusting when the idea needs it: + +- `# RFC: ` +- `Status: proposed` +- `## Problem`: name the current surface, cite the relevant files, and state the consumer evidence. Separate production callers from tests/docs. +- `## Proposal`: say exactly what to remove, fold, demote, or rehome. Include tests, docs, READMEs, JSDoc, event-taxonomy, snapshot, and generated-file cleanup when relevant. +- `## Why not keep it?` or `## What we give up`: make the strongest counterargument legible. +- `## Acceptance criteria`: observable end state and gates. +- `## Risks`: public API changes, behavior changes, future product wants, and why the tradeoff is still reasonable. + +Be concrete enough that an implementing PR can follow the trail. Avoid vague "simplify this package" RFCs. When a proposal overlaps an existing RFC, consolidate the useful details into the existing one rather than creating a duplicate. + +## Inline TODO Notes + +Use inline TODO/FIXME/XXX only for small, local cleanups that are clearly useful but not durable design decisions. Keep them short and actionable: + +- Name the smell with a stable tag, e.g. `TODO(double-default)` or `XXX(unused-default)`. +- Explain why it is safe to revisit and what action would simplify it. +- Do not add TODOs for speculative complaints or for behavior that needs an RFC-level decision. + +## When Folding Another PR Or Branch + +Diff the sibling branch against `origin/master`, not against the current PR branch, so you see its independent contribution. For each item: + +- Port non-overlapping RFCs or TODOs that meet the quality bar. +- Consolidate overlapping material into the existing RFC that owns the topic. +- Do not port duplicate or lower-confidence proposals just to preserve the count. +- Update the PR body so reviewers see the true candidate count and scope. +- Close the duplicate PR only when the user asked you to, or when you clearly own that housekeeping. + +## Validation And PR Hygiene + +For docs-only RFC work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Before pushing, expect the pre-push hook to run module graph freshness, unit tests, snapshots, doc-sync, and hygiene. + +When opening or updating a PR, summarize: + +- How many RFCs and inline notes were added. +- The main areas surveyed. +- What was intentionally excluded. +- Which checks passed. + +Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled. diff --git a/.agents/skills/dsh-find-simplifications/agents/openai.yaml b/.agents/skills/dsh-find-simplifications/agents/openai.yaml new file mode 100644 index 0000000000..4016526f85 --- /dev/null +++ b/.agents/skills/dsh-find-simplifications/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "DSH Find Simplifications" + short_description: "Find dead or overbuilt harness surfaces" + default_prompt: "Use $dsh-find-simplifications to find simplification candidates and write proposed RFCs." From 80179a5ed0c106af81a6a0230b7ae55bb655e78a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:34:56 +0800 Subject: [PATCH 41/87] docs: address simplification review feedback --- .../agents/openai.yaml | 4 -- docs/rfc/README.md | 32 ++++++------ .../proposed/2026-06-14-acp-multi-session.md | 2 +- ...6-20-collapse-trace-only-session-events.md | 21 +++++--- .../2026-06-20-discover-package-inventory.md | 6 +-- ...2026-06-20-drop-durable-step-boundaries.md | 2 +- ...rop-unconsumed-llm-adapter-change-event.md | 43 ++++++++++++++++ ...-drop-unconsumed-llm-assembled-surfaces.md | 45 +++++++++++++++++ ...6-06-20-drop-unconsumed-llm-block-views.md | 44 ---------------- ...-drop-unconsumed-registry-change-events.md | 50 ------------------- .../2026-06-20-foreground-only-bash.md | 27 ---------- ...06-20-generic-long-running-tool-runtime.md | 35 +++++++++++++ .../2026-06-20-public-agent-stop-surface.md | 4 +- ...-20-remove-agent-boundary-mirror-events.md | 2 +- ...0-remove-redundant-snapshot-log-goldens.md | 8 +-- ...06-20-assembled-assistant-messages-only.md | 6 +-- .../2026-06-20-classify-support-packages.md | 4 +- .../2026-06-20-drop-acp-session-load.md | 4 +- .../2026-06-20-drop-acp-terminal-meta.md | 4 +- ...2026-06-20-drop-bash-output-spill-files.md | 4 +- .../2026-06-20-drop-unused-session-lineage.md | 4 +- ...6-20-fold-session-persistence-interface.md | 2 +- .../2026-06-20-generic-tool-rendering.md | 2 +- .../2026-06-20-retire-mid-turn-steering.md | 4 +- .../2026-06-20-single-session-acp-bridge.md | 8 +-- .../2026-06-20-truncate-interrupted-turns.md | 4 +- packages/acp/README.md | 2 +- 27 files changed, 189 insertions(+), 184 deletions(-) delete mode 100644 .agents/skills/dsh-find-simplifications/agents/openai.yaml create mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md create mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md delete mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md delete mode 100644 docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md delete mode 100644 docs/rfc/proposed/2026-06-20-foreground-only-bash.md create mode 100644 docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md rename docs/rfc/{proposed => rejected}/2026-06-20-assembled-assistant-messages-only.md (85%) rename docs/rfc/{proposed => rejected}/2026-06-20-classify-support-packages.md (82%) rename docs/rfc/{proposed => rejected}/2026-06-20-drop-acp-session-load.md (85%) rename docs/rfc/{proposed => rejected}/2026-06-20-drop-acp-terminal-meta.md (89%) rename docs/rfc/{proposed => rejected}/2026-06-20-drop-bash-output-spill-files.md (79%) rename docs/rfc/{proposed => rejected}/2026-06-20-drop-unused-session-lineage.md (79%) rename docs/rfc/{proposed => rejected}/2026-06-20-fold-session-persistence-interface.md (90%) rename docs/rfc/{proposed => rejected}/2026-06-20-generic-tool-rendering.md (93%) rename docs/rfc/{proposed => rejected}/2026-06-20-retire-mid-turn-steering.md (87%) rename docs/rfc/{proposed => rejected}/2026-06-20-single-session-acp-bridge.md (64%) rename docs/rfc/{proposed => rejected}/2026-06-20-truncate-interrupted-turns.md (90%) diff --git a/.agents/skills/dsh-find-simplifications/agents/openai.yaml b/.agents/skills/dsh-find-simplifications/agents/openai.yaml deleted file mode 100644 index 4016526f85..0000000000 --- a/.agents/skills/dsh-find-simplifications/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "DSH Find Simplifications" - short_description: "Find dead or overbuilt harness surfaces" - default_prompt: "Use $dsh-find-simplifications to find simplification candidates and write proposed RFCs." diff --git a/docs/rfc/README.md b/docs/rfc/README.md index b75f95d0bf..84677bda8a 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -32,27 +32,16 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Unify the agent id and the session id](proposed/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Retire mid-turn steering](proposed/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](proposed/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Keep one public stop primitive](proposed/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Drop the unconsumed `streamBlocks()` assembled-view surface](proposed/2026-06-20-drop-unconsumed-llm-block-views.md) | 2026-06-20 | -| [Drop the unconsumed registry `*/change` events](proposed/2026-06-20-drop-unconsumed-registry-change-events.md) | 2026-06-20 | +| [Drop unconsumed assembled LLM convenience surfaces](proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | +| [Drop the unconsumed `llm/adapter-change` event](proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | | [Prune dead methods from the persistence and bash seams](proposed/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Drop durable step boundary events](proposed/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | -| [Truncate interrupted final turns on load](proposed/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | -| [Persist assembled assistant messages, not stream chunks](proposed/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | -| [Collapse trace-only session events](proposed/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | -| [Drop unused session lineage metadata](proposed/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | -| [Make the bash tool foreground-only](proposed/2026-06-20-foreground-only-bash.md) | 2026-06-20 | -| [Drop bash full-output spill files](proposed/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | -| [Collapse tool-owned UI presentation](proposed/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | -| [Drop ACP terminal `_meta` rendering](proposed/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | -| [Return the ACP bridge to one live session per connection](proposed/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | -| [Drop ACP session/load until resume has a product shape](proposed/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | +| [Fold trace-only session facts into load-bearing events](proposed/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Extract a generic long-running tool runtime](proposed/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/2026-06-20-providerless-example-base.md) | 2026-06-20 | -| [Classify product, integration, and support packages](proposed/2026-06-20-classify-support-packages.md) | 2026-06-20 | -| [Fold the persistence interface into dsh-session](proposed/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | -| [Remove redundant recorded snapshot log goldens](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Use the recorded session fixture as the snapshot log golden](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | | [Discover package inventories instead of maintaining static lists](proposed/2026-06-20-discover-package-inventory.md) | 2026-06-20 | ## Implemented @@ -90,3 +79,14 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Deep-readonly public surfaces](rejected/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | +| [Persist assembled assistant messages, not stream chunks](rejected/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | +| [Classify product, integration, and support packages](rejected/2026-06-20-classify-support-packages.md) | 2026-06-20 | +| [Drop ACP session/load until resume has a product shape](rejected/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | +| [Drop ACP terminal `_meta` rendering](rejected/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | +| [Drop bash full-output spill files](rejected/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | +| [Drop unused session lineage metadata](rejected/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | +| [Fold the persistence interface into dsh-session](rejected/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | +| [Collapse tool-owned UI presentation](rejected/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | +| [Retire mid-turn steering](rejected/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | +| [Return the ACP bridge to one live session per connection](rejected/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | +| [Truncate interrupted final turns on load](rejected/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/2026-06-14-acp-multi-session.md index 33d4a7c177..3b73726607 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/2026-06-14-acp-multi-session.md @@ -5,7 +5,7 @@ Status: proposed > **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. -> **Competing simplification:** [Return the ACP bridge to one live session per connection](2026-06-20-single-session-acp-bridge.md) proposes reversing the multiplexing scope until the product has a concrete multi-session UX and permission model. While both RFCs remain proposed, this one represents the "finish multiplexing" path and the newer RFC represents the "remove multiplexing" path. +> **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../rejected/2026-06-20-single-session-acp-bridge.md). ## Problem diff --git a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md index 143f3e822a..ecba30bcbd 100644 --- a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md @@ -1,4 +1,4 @@ -# RFC: Collapse trace-only session events +# RFC: Fold trace-only session facts into load-bearing events Status: proposed @@ -6,23 +6,28 @@ Status: proposed The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. `usage` is already present as a model stream chunk before the loop also appends a separate `usage` event. `error` duplicates the `turn/end { kind: 'error', message, code }` reason for loop failures; ACP settlement reads the turn-end reason, ACP rendering ignores the `error` event, and `deriveMessages()` skips it. -These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing for resume. The implemented [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md) already says post-turn operational diagnostics do not belong in the replayable session log. +These events make the canonical transcript look more useful as telemetry than it currently is. They add event variants, invariants, tests, snapshots, and persistence cases, but they are not load-bearing as separate records. The facts they carry can still be useful: token usage should remain available for accounting, and an error's step number should not silently disappear. The simplification is to fold those facts into nearby events consumers already must understand, not to record less information. ## Proposal -Remove trace-only events from the canonical session log unless a production consumer needs them. Model usage can be derived from retained stream chunks, attached to `assistant/message`, or emitted on a separate telemetry channel. Loop errors should be represented by `turn/end.reason` for durable transcript semantics and `agent/error` or logging for operational diagnostics. Do not keep a parallel `error` event that consumers must reconcile with the final turn reason. +Remove standalone trace-only events only where their information can be preserved without a parallel record: -If analytics become real, add a projection helper or a dedicated telemetry store with its own retention policy. The user conversation log should contain what is needed to render, resume, and audit the interaction, not every metric-shaped detail the loop happened to observe. +- Fold successful-step usage into the matching `assistant/message`, e.g. `assistant/message { turn, step, content, usage? }`, so the assembled model output and its accounting travel together. +- For a failed or aborted step that has usage but no `assistant/message`, carry the usage on the terminal turn reason or another load-bearing failure record in the same turn. The implementing design must prove no usage chunk that is currently persisted becomes unrepresented. +- Fold the step number from the standalone `error` event into `turn/end.reason` for `kind: 'error'`, e.g. `{ kind: 'error', step, message, code? }`. `turn/end` is the durable turn outcome ACP and resume already consume. +- Keep `agent/error` and logging for live diagnostics; do not add a second session-log error record after `turn/end`. + +If analytics become real, add a projection helper or a dedicated telemetry store with its own retention policy. The user conversation log should contain what is needed to render, resume, audit, and account for the interaction without requiring consumers to reconcile duplicate trace rows. ## Acceptance criteria -- `SessionEventMap` drops `usage` and `error`, or folds their fields into nearby load-bearing events. +- `SessionEventMap` drops standalone `usage` and `error` only after their fields are represented on load-bearing session events. - The loop no longer appends a separate `usage` event for a usage chunk. -- The loop records durable failures only as `turn/end { kind: 'error' }` and reports live diagnostics through `agent/error`. +- The loop records durable failures through `turn/end { kind: 'error', step, message, code? }` or an equivalent no-information-loss shape and reports live diagnostics through `agent/error`. - ACP snapshots and persistence tests stop asserting trace-only lines. -- Documentation explains where token usage and operational errors are observed if they remain available. +- Documentation explains exactly where token usage and operational errors are observed. - The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up -A consumer can no longer filter the canonical log for `usage` or step-level `error` events. That is a real loss for future analytics and debugging, but there is no current production analytics consumer. Keeping a telemetry-shaped event in the replay log because it might matter later repeats the dead-summary pattern from [drop the mutable session summary](../implemented/2026-06-19-drop-mutable-session-summary.md). +A consumer can no longer filter the canonical log for standalone `usage` or step-level `error` rows. It must read those facts from the assistant/failure events that carry them. That is a reasonable simplification only if the implementing PR proves the same facts remain present; otherwise the standalone events should stay. diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md index f346b8bb91..c75f93fd99 100644 --- a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md @@ -10,13 +10,13 @@ Static lists are appropriate when they encode policy; they are needless friction ## Proposal -Make package/gate inventories discoverable. Publishability should come from explicit package classification metadata, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make package/gate inventories discoverable. Publishability should come from explicit package aspect metadata, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. -This pairs well with [classifying support packages](2026-06-20-classify-support-packages.md), because discovery needs to know which packages are product-publishable, support-only, private, or examples. +The metadata should be aspect-oriented rather than a single support/product bucket: a package may be core, bash-related, filesystem-related, persistence-related, provider-facing, example-facing, testing-only, and/or publishable. Discovery needs enough explicit facts to drive gates without baking a fragile hierarchy into every script. ## Acceptance criteria -- `publint-all` discovers publishable packages from manifests plus a single classification source. +- `publint-all` discovers publishable packages from manifests plus explicit aspect metadata. - Adding a package does not require editing a static package list for every gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md index 4bb5938b59..ad424cb06e 100644 --- a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md @@ -12,7 +12,7 @@ The boundary events make the log more ceremonial than informative. The loop trac Make the turn the only durable boundary. Remove `step/start` and `step/end` from `SessionEventMap`; keep the numeric `step` field on events that need grouping. The loop increments the step counter and records step-scoped events with that number, but it no longer appends open/close boundary events. Consumers infer step groups from contiguous events sharing `(turn, step)`. -The invariants plugin should enforce that step-scoped events have valid positive step numbers within an open turn, not that separate boundary records surround them. Crash repair should not synthesize `step/end`; if [interrupted turns are truncated](2026-06-20-truncate-interrupted-turns.md), the repair path disappears entirely. +The invariants plugin should enforce that step-scoped events have valid positive step numbers within an open turn, not that separate boundary records surround them. Crash repair should not synthesize `step/end`; if an interrupted turn is preserved, the repair path can still close the turn without inventing step boundary records. ## Acceptance criteria diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md new file mode 100644 index 0000000000..ab7f23296b --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -0,0 +1,43 @@ +# RFC: Drop the unconsumed `llm/adapter-change` event + +Status: proposed + +## Problem + +`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. + +This differs from `tools/change` and `system-prompt/change`. Those two events are also unconsumed today, but they are plausible registry-change signals for future live tool/prompt UIs. LLM adapter registration is more of a boot-time implementation detail: adapters are not a user-visible palette and the real model-call interception seam is `llm/stream`. Keeping an adapter-change event with no listener repeats the [drop-the-dead-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern at a smaller scale. + +The event is not free. `registerAdapter()` yields its rollback disposer before emitting `llm/adapter-change` so a throwing listener unwinds the mutation instead of leaking an adapter entry, and the package carries tests for that listener-throw path. That defensive ordering protects a failure mode only tests can trigger. + +## Proposal + +Remove only `llm/adapter-change`: + +- Delete the `llm/adapter-change` declaration from `dsh-llm`'s `interface Events`. +- Delete the `ctx.emit('llm/adapter-change')` calls. +- Simplify `registerAdapter()`'s effect generator: keep the mutation and rollback disposer for HMR/disposal, but drop the listener-throw rollback ordering that exists only for the removed event. +- Remove the "Emits `llm/adapter-change` on registration and disposal" sentence from `LlmService.registerAdapter`'s JSDoc. +- Rewrite the adapter-disposer test to assert the returned disposer removes the adapter without subscribing to `llm/adapter-change`; delete the listener-throw rollback test that exists solely for the removed event. +- Update the event taxonomy table in [docs/architecture.md](../../../docs/architecture.md) and [packages/llm/README.md](../../../packages/llm/README.md). The [doc-sync-enforcement RFC](../implemented/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. + +## Why not remove every registry change event? + +A microkernel where registries announce mutations is a coherent convention. `tools/change` and `system-prompt/change` may become useful when a UI can live-refresh available tools or prompt sections. This RFC leaves that convention intact where it has a plausible user-facing consumer and cuts only the adapter-change event whose current and likely future consumer is unclear. + +If an LLM adapter browser or dynamic model-picker needs this signal later, reintroduce it with that consumer and a clearer payload than "something changed." + +## Acceptance criteria + +- `llm/adapter-change` and its emits are gone; `pnpm run verify-event-taxonomy` passes against the updated table. +- HMR-safety tests still pass: disposing a contributing fiber still removes the adapter. +- `tools/change` and `system-prompt/change` remain documented and tested. +- `pnpm run test:coverage` stays 100% per-file. +- No production code path changes observable behavior (verified by unchanged ACP snapshot goldens and the echo-agent smoke test). + +## Risks + +- **Removing a documented emit event is a public-surface change.** It is in the taxonomy table, so it reads as deliberate API. But "declared and emitted" is not "consumed" — the same distinction that justified dropping the mutable summary. The taxonomy table is updated in the same change, so the docs do not drift. +- **The registry-change convention becomes uneven.** That is acceptable because LLM adapter registration is not the same user-facing concept as tools or prompt sections. Uneven but honest beats uniform but dead. + +This is a small cut, but it retires a standing correctness invariant that guards a consumer that does not exist. diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md new file mode 100644 index 0000000000..85198e7d84 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -0,0 +1,45 @@ +# RFC: Drop unconsumed assembled LLM convenience surfaces + +Status: proposed + +## Problem + +`LlmService` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: + +- `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. +- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../packages/llm/src/index.ts)). +- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../packages/llm/src/index.ts)). + +The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. + +This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data. + +`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. + +## Proposal + +Make `stream()` the only public LLM call surface: + +- Remove `LlmService.streamBlocks()` and its JSDoc. +- Remove `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult` if no surviving API needs that named result shape. +- Remove `BlockAssembler.flushReady()`, `BlockAssembler.flushRemaining()`, and the `flushed` cursor field. +- Remove `BlockAssembler.result()` if it is only a helper for the deleted `generate()` service path and tests. +- Replace adapter-test use of `ctx.llm.generate()` with a small test helper that calls `ctx.llm.stream()`, pushes chunks into `BlockAssembler`, and returns the assembled message, usage, and finish reason needed by that test. That keeps the [twin-adapter design](../implemented/2026-06-13-twin-llm-adapters.md) intact while avoiding a public method whose only callers are tests. +- Remove or rework the `flushReady`/`flushRemaining`-dependent tests. Keep assembler invariants that still apply to `push()` / `blocks()` / `message()`; delete behavior that only pins the removed flush API. +- Update every doc/comment reference to `streamBlocks`, `generate`, `GenerateResult`, and `llm/generate` across `docs/`, package READMEs, and source comments. The `ctx.llm` service-map row in [docs/architecture.md](../../../docs/architecture.md) becomes `stream()` only, the event taxonomy drops `llm/generate`, and the [property-based-testing RFC](../implemented/2026-06-11-property-based-testing.md) names block-assembly invariants without referring to removed convenience methods. + +## Acceptance criteria + +- `streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone require are gone; `pnpm run knip` reports no new dead exports. +- `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). +- Adapter tests still exercise both real adapters through `stream()` and the shared assembler, not through a test-only public shortcut. +- The loop behaves identically — verified by unchanged ACP snapshot goldens. +- `packages/llm/README.md`, [docs/architecture.md](../../../docs/architecture.md), and module docs no longer mention the removed convenience surfaces. + +## Risks + +- **It removes public methods from a core vocabulary package.** A future plugin that wants assembled blocks without deltas would need to call `stream()` and use `BlockAssembler` directly or reintroduce a focused helper with a real consumer. Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../AGENTS.md)), this is the right time to cut test-only public shape. +- **Adapter tests get a little more explicit.** They lose the ergonomic `generate()` wrapper, but that is useful pressure: tests exercise the same streaming path production uses. +- **Waterfall users lose `llm/generate`.** No production listener exists. Any future caching/retry/logging plugin should wrap `llm/stream`, which remains the single provider call path. + +The size is modest, but it is a clean removal of speculative surface area from the LLM package, leaving one model-call contract for both production and tests. diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md deleted file mode 100644 index a0fb71d40b..0000000000 --- a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-block-views.md +++ /dev/null @@ -1,44 +0,0 @@ -# RFC: Drop the unconsumed `streamBlocks()` assembled-view surface on `dsh-llm` - -Status: proposed - -## Problem - -`LlmService` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: - -- `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. -- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../packages/llm/src/index.ts)). -- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../packages/llm/src/index.ts)). - -The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding the raw chunks through its own `BlockAssembler` so it can log raw chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` across `packages/*/src` and `examples/*/src` finds zero callers; the only references are the method itself, two doc comments, and two test files (`llm/tests/properties.spec.ts`, `agent-loop/tests/review-fixes.spec.ts`). - -This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: an entire assembled-view API with a property-tested contract, consumed by nothing but its own tests. It was built speculatively for "consumers that don't care about token-level deltas" that never materialized — the one real consumer cares about deltas precisely so it can log them. - -`streamBlocks()` also drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support the incremental in-order yield. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — never the streaming flush. With `streamBlocks()` gone, `flushReady`/`flushRemaining`/`flushed` are dead too. - -## Proposal - -Delete `streamBlocks()` and the assembler's streaming-flush machinery it alone drives: - -- Remove `LlmService.streamBlocks()` and its JSDoc. -- Remove `BlockAssembler.flushReady()`, `BlockAssembler.flushRemaining()`, and the `flushed` cursor field. -- Remove or rework the `flushReady`/`flushRemaining`-dependent tests: in `llm/tests/properties.spec.ts` the `flushReady() ++ flushRemaining() === blocks()` property, the strict-order property, and the "streaming and one-shot assembly agree on usage and finish" property (which pushes-then-flushes incrementally) all exercise the streaming-flush path; the `flushRemaining` cases in `llm/tests/assembler.spec.ts` and the three `streamBlocks` edge-case tests in `agent-loop/tests/review-fixes.spec.ts` likewise. Each is either deleted or, where it also asserts a non-flush invariant worth keeping (e.g. streaming vs one-shot agreeing on usage/finish), rewritten to use `push()` + `message()`/`result()` without the removed flush methods — the behavior pinned to the deleted methods goes, per AGENTS.md "tests document behavior, not golden truth". -- Update every doc/comment reference to `streamBlocks` — grep it across `docs/`, `packages/llm/README.md`, and source comments. `packages/llm/README.md` mentions it twice (the API-list row and the `BlockAssembler` "used by `streamBlocks()`/`generate()`" line); the `assembler.ts` module doc references it; and the retained `generate()` JSDoc currently reads "Same completion guarantees as `streamBlocks()`" — reword it to state the guarantee directly. The `ctx.llm` service-map row in [docs/architecture.md](../../../docs/architecture.md) (`stream()` / `streamBlocks()` / `generate()`) drops `streamBlocks()` too. The [property-based-testing RFC](../implemented/2026-06-11-property-based-testing.md) needs two edits: its motivating anecdote ("a `streamBlocks` ordering bug") is reworded to name the bug class (a block-assembly ordering bug) rather than a removed method, and its dsh-llm invariant list — which names `flushReady()+flushRemaining() ≡ blocks()` as a checked property — is updated to drop the removed-method invariant and keep only the ones the surviving assembler API (`push`/`blocks`/`message`/`result`) still supports. - -## Scope: why `generate()` and `llm/generate` stay - -`generate()` is not dead the same way: the twin-adapter e2e/unit suites (`llm-deepseek`, `llm-pi-ai`) use `ctx.llm.generate({...})` as a convenient one-shot driver to assert provider behavior, and `GenerateResult` / `assembler.result()` back it. Those adapter tests are the [twin-adapter design](../implemented/2026-06-13-twin-llm-adapters.md), explicitly out of scope for a simplification pass. Removing `generate()` would force adapter-test call sites to hand-drain `stream()`, which is churn in protected territory for a method that is at least a legitimate ergonomic driver. So this RFC deliberately stops at the surface that nothing — not even an out-of-scope test — consumes. If a later pass wants to also collapse `generate()`/`llm/generate`/`result()`, that is a separate decision with a real caller to migrate. - -## Acceptance criteria - -- `streamBlocks` and the assembler streaming-flush methods are gone; `pnpm run knip` reports no new dead exports. -- `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). -- `generate()`, `stream()`, `result()`, `blocks()`, `message()` are untouched and the loop behaves identically — verified by the unchanged ACP snapshot goldens. -- `packages/llm/README.md` and the module docs no longer mention `streamBlocks`. - -## Risks - -- **It removes a public method from a core vocabulary package.** A future plugin that wants "assembled blocks without the deltas" would have to re-add it (or call `generate()` and read `.message.content`). Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../AGENTS.md)) and that the obvious assembled-view need is already served by `generate()`, this is the right time to cut — re-adding a thin assembler wrapper later is trivial if a real consumer appears. -- **Low blast radius.** The change is confined to `dsh-llm`; no other package imports `streamBlocks` or the flush methods, so there is no cross-package ripple. - -The size is modest, but it is a clean, zero-production-impact removal of a speculative surface — the cheapest kind of correctness. diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md b/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md deleted file mode 100644 index 16c1b8faf2..0000000000 --- a/docs/rfc/proposed/2026-06-20-drop-unconsumed-registry-change-events.md +++ /dev/null @@ -1,50 +0,0 @@ -# RFC: Drop the unconsumed registry `*/change` notification events - -Status: proposed - -## Problem - -Three registries each emit a "something changed" notification event that no production listener subscribes to: - -- `tools/change` — emitted by `ToolRegistry.register()` on register and disposal ([packages/tools/src/index.ts:302-304](../../../packages/tools/src/index.ts)). -- `system-prompt/change` — emitted by `SystemPrompt.section()` and `.tools()` ([packages/system-prompt/src/index.ts:86-110](../../../packages/system-prompt/src/index.ts)). -- `llm/adapter-change` — emitted by `LlmService.registerAdapter()` ([packages/llm/src/index.ts:98-100](../../../packages/llm/src/index.ts)). - -Grepping the three event names across `packages/*/src` and `examples/*/src` finds only the emit sites and their declarations — zero `ctx.on('.../change')` listeners in production. The only subscribers are each package's own spec file, and they subscribe purely to test that the emit fires. They are listed in the event taxonomy table ([docs/architecture.md](../../../docs/architecture.md)) as `emit` events, but nothing reacts to them. - -These events are speculative generality for a hypothetical reactive consumer (a UI that live-refreshes its tool palette, say) that does not exist. That alone would be a mild [drop-the-dead-summary](../implemented/2026-06-19-drop-mutable-session-summary.md)-style cut. What makes it worth an RFC is the machinery the events drag along: to emit `.../change` safely, each registry orders its generator effect so the rollback disposer is `yield`ed before the change-emit, specifically so a throwing change-listener unwinds the mutation instead of leaking a registry entry. Every one of the three carries a multi-line comment justifying this ordering, plus a dedicated "rollback when a change listener throws" test. That is a non-trivial correctness burden guarding a failure mode that only the tests' own injected listeners can trigger, because there are no real listeners. - -## Proposal - -Remove the three `*/change` events and the defensive machinery that exists only to make them safe: - -- Delete the `tools/change`, `system-prompt/change`, `llm/adapter-change` declarations from each package's `interface Events`. -- Delete the `ctx.emit('.../change')` calls. -- Simplify each `ctx.effect` generator: the mutation and its rollback disposer remain (HMR/disposal still need them), but the "yield rollback before the emit so a throwing listener rolls back" ordering comment and any emit-after-yield collapse to a plain `set`/`push` plus a `yield () => delete`/`splice`. No behavior an external observer can see changes, because nothing observes the events. -- Remove the "Emits `.../change` on register/unregister" sentence from the surviving registration-method JSDocs — these sit on methods that stay, so they go stale rather than vanish with the deleted code: `LlmService.registerAdapter` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)), `ToolRegistry.register` ([packages/tools/src/index.ts](../../../packages/tools/src/index.ts)), and both `SystemPrompt.section` and `SystemPrompt.tools` ([packages/system-prompt/src/index.ts](../../../packages/system-prompt/src/index.ts)). -- Delete or rewrite the tests that exist to exercise the events. The change-listener-rollback tests are deleted outright (the rollback behavior goes with the event). The positive emission-subscriber tests are handled case by case: `system-prompt/tests/system-prompt.spec.ts`'s "emits system-prompt/change ..." is deleted (its disposal coverage is duplicated by the separate "cleans up tool providers on fiber dispose" / "removes section when returned disposer is called directly" tests), but `llm/tests/service.spec.ts`'s "disposes adapter registration on adapter-change event emission" is the only test that calls the `registerAdapter()` returned disposer and asserts the adapter is removed (the HMR test at "unregisters adapters when the owning fiber is disposed" covers fiber disposal, a different path) — so it is rewritten to drop the event subscription while keeping the returned-disposer assertion, not deleted. Per AGENTS.md "tests document behavior, not golden truth". -- Update the event taxonomy table in [docs/architecture.md](../../../docs/architecture.md) (remove the three rows) and re-run `pnpm run verify-event-taxonomy`, which mechanically checks the table against source. Also remove the per-package README event rows that list them: [packages/tools/README.md](../../../packages/tools/README.md) (`tools/change`), [packages/system-prompt/README.md](../../../packages/system-prompt/README.md) (`system-prompt/change`), and [packages/llm/README.md](../../../packages/llm/README.md) (`llm/adapter-change`). The [doc-sync-enforcement RFC](../implemented/2026-06-11-doc-sync-enforcement.md), whose `verify-event-taxonomy` description names these three as the events that surfaced when the check landed, is reworded so its example does not point at removed events. - -## Why not keep them as a "registries announce changes" convention? - -That is the honest counter-argument: a microkernel where every registry announces its mutations is a clean, uniform reactive substrate, and a future live UI would want exactly this. Three considerations push the other way: - -1. **The harness already has a finer-grained feed for the one realistic consumer.** A UI live-renders from `session/event` and `agent/*`, not from registry mutations — tools/sections/adapters are registered at plugin-load time and effectively static during a session. The `.../change` events fire almost exclusively during boot and HMR, when nothing is watching. -2. **Pre-release stance.** [AGENTS.md](../../../AGENTS.md) says optimize for the correct foundation, not a speculative future; add the seam when a real consumer needs it. Re-adding an emit is one line; the cost today is the standing rollback-ordering burden on three hot registration paths. -3. **The events are not free — they shape the registration code.** Keeping them means keeping the throwing-change-listener invariant and its tests forever, for a listener that cannot exist until someone adds one. - -If a reactive consumer is later built, it should be reintroduced deliberately, as one coherent decision about which registries announce what (and possibly a single `registry/change` shape), not as three independently-grown emits nothing reads. - -## Acceptance criteria - -- The three events and their emits are gone; `pnpm run verify-event-taxonomy` passes against the updated table. -- HMR-safety tests still pass: disposing a contributing fiber still removes the tool/section/adapter (the rollback disposer is retained; only the change-emit and its throwing-listener guard are removed). -- `pnpm run test:coverage` stays 100% per-file. -- No production code path changes observable behavior (verified by unchanged ACP snapshot goldens and the echo-agent smoke test). - -## Risks - -- **Removing a documented emit event is a public-surface change.** It is in the taxonomy table, so it reads as deliberate API. But "declared and emitted" is not "consumed" — the same distinction that justified dropping the mutable summary. The taxonomy table is updated in the same change, so the docs do not drift. -- **A registry that genuinely wants change-notification later pays a small reintroduction cost.** Judged acceptable per the pre-release stance; the reintroduction is mechanical. - -This is a small-to-medium cut across three packages and, more valuably, it retires a standing correctness invariant that guards a consumer that does not exist. diff --git a/docs/rfc/proposed/2026-06-20-foreground-only-bash.md b/docs/rfc/proposed/2026-06-20-foreground-only-bash.md deleted file mode 100644 index 3197e916ca..0000000000 --- a/docs/rfc/proposed/2026-06-20-foreground-only-bash.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Make the bash tool foreground-only - -Status: proposed - -## Problem - -The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. - -The [tool cookbook](../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. - -## Proposal - -Temporarily collapse `bash` to foreground-only execution. Remove the model-facing `run_in_background` schema field, the `bash_output` and `bash_kill` tools, background task ownership, incremental task reads, completion injection, and task-listener APIs from the bash executor seam. The `BashExecRequest` request type is already foreground-shaped; the removal surface is the tool schema plus the executor's background-task methods. Long commands can still run with an explicit timeout; a command that needs to outlive a model step is not supported until a generic task service exists. - -If long-running tasks return later, implement them once as a capability-agnostic task layer that owns ids, authorization, polling, cancellation, completion notifications, and any UI affordances. Bash can then opt into that layer like any other tool. - -## Acceptance criteria - -- `@deepseek-ai/dsh-tool-bash` registers only the `bash` tool. -- `BashExecutor` exposes `resolve()` and foreground `run()` only. -- `@deepseek-ai/dsh-bash-local` no longer tracks background task maps, owner tokens, task listeners, or incremental output cursors. -- ACP and snapshot fixtures no longer mention `bash_output` or `bash_kill`. -- The [tool cookbook](../../cookbook/adding-a-tool.md) either removes the background example or redirects long-running work to a future generic task proposal. - -## What we give up - -The model loses the ability to start a server or long-running command, continue other work, and poll later. That is a real capability regression, but the current design makes one tool carry infrastructure that belongs above all tools. Foreground-only bash is smaller, safer, and easier to sandbox while the generic long-running-tool design is still absent. diff --git a/docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md new file mode 100644 index 0000000000..825fb133fe --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md @@ -0,0 +1,35 @@ +# RFC: Extract a generic long-running tool runtime + +Status: proposed + +## Problem + +The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. + +The [tool cookbook](../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. + +## Proposal + +Move long-running task semantics above bash into a tool-agnostic runtime. Bash remains able to run background commands, but it stops owning the general concepts of task ids, ownership tokens, polling, cancellation, completion notifications, and model-facing "read/kill this task" commands. + +The runtime should own: + +- Stable task ids and owner tokens keyed to the calling session/agent. +- Registration of a long-running task with a producer for incremental output and a completion promise. +- Generic read/cancel/list operations with the same cross-session authorization rule for every tool. +- Completion notification injection into the owning session. +- Presentation hooks for pending/running/completed task state, with bash supplying only command-specific labels and output formatting. + +`dsh-bash` then keeps the bash-specific execution contract: resolve a request into a command spec, run a foreground command, or start a process and hand its streams/process handle to the generic runtime. `dsh-tool-bash` keeps the model-facing command tool, but the follow-up operations become generic long-running-tool operations or a shared utility that bash registers with, rather than bespoke `bash_output`/`bash_kill` plumbing. + +## Acceptance criteria + +- The bash-specific packages no longer define the generic task registry, owner-token authorization, polling, cancellation, or completion-notification machinery. +- A shared long-running-task service or tool layer owns those semantics and is documented as the path for any future background-capable tool. +- Bash background behavior remains available through the shared layer, with tests proving cross-session isolation still holds. +- ACP and snapshot fixtures render background bash through the shared task vocabulary, not through bash-only lifecycle semantics. +- The [tool cookbook](../../cookbook/adding-a-tool.md) points long-running tools at the shared runtime instead of telling each tool to invent its own task protocol. + +## What we give up + +The bash package loses local ownership of an already-working background-task implementation, and the implementing PR may temporarily churn model-facing tool names or transcript presentation. That churn is worthwhile if it leaves one background-task contract instead of making every future long-running tool clone bash's private protocol. diff --git a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md index 8ff4ebb46b..6c67413a49 100644 --- a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md @@ -18,7 +18,7 @@ Delete public `abort()` and `whenIdle()`, the tests that exercise them as standa ## Acceptance criteria -- `Agent` exposes no public `abort()` or `whenIdle()`; if [retiring mid-turn steering](2026-06-20-retire-mid-turn-steering.md) has not landed, `steer()` remains part of the message surface. +- `Agent` exposes no public `abort()` or `whenIdle()`; `steer()` remains part of the message surface. - ACP cancellation continues to call `cancel()`. - Agent teardown continues to await quiescence through handle disposal. - Tests cover cancellation and disposal as the two supported stop paths. @@ -29,4 +29,4 @@ A future plugin cannot abort only the current model/tool step while preserving q ## Related -This RFC only removes the stop/quiescence methods. If it lands before [retiring mid-turn steering](2026-06-20-retire-mid-turn-steering.md), `steer()` remains part of the `Agent` message surface; if the steering RFC lands first, the resulting surface is `send()`, `inject()`, `cancel()`, status, options, session, and identity. +This RFC only removes the stop/quiescence methods. Mid-turn steering remains an intentional message path; the resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, status, options, session, and identity. diff --git a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md index cbb299dfca..4b1cd75a56 100644 --- a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md @@ -28,4 +28,4 @@ A plugin can no longer observe turn/step boundaries from a convenient `Agent`-fi ## Related -This is compatible with [assembled assistant messages only](2026-06-20-assembled-assistant-messages-only.md), but the exact fate of `agent/stream-chunk` depends on that decision. If chunks leave the canonical log, `agent/stream-chunk` can remain as a deliberately live-only UI signal while the other mirror events disappear. +Because high-fidelity `assistant/chunk` persistence remains load-bearing, `agent/stream-chunk` can be evaluated as another mirror of durable session data rather than as the only token stream. If a future proposal moves chunks out of the canonical log, `agent/stream-chunk` would need a fresh decision as a deliberately live-only UI signal. diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md index aedb1b9a40..cf853ce341 100644 --- a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,4 +1,4 @@ -# RFC: Remove redundant recorded snapshot log goldens +# RFC: Use the recorded session fixture as the snapshot log golden Status: proposed @@ -6,17 +6,17 @@ Status: proposed Recorded ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -The duplicate file can help review by showing "expected persisted log" separately from "model replay input", but for recorded scenarios those are intentionally the same artifact. Keeping both means a re-record churns two files with the same semantic content. +The duplicate file can help review by showing "expected persisted log" separately from "model replay input", but for recorded scenarios those are intentionally the same artifact. Keeping both means a re-record churns two files with the same semantic content, when one committed session log can serve as both replay input and expected persisted output. ## Proposal -For recorded scenarios, compare the replay run's normalized session log directly against normalized `session.jsonl`. Keep explicit `session.golden.jsonl` only for authored scenarios where `replay.override.json` drives behavior that is not derivable from the fixture, or where the expected persisted log intentionally differs from the replay script. +For recorded scenarios, keep one session-log artifact: `session.jsonl`. The snapshot test compares the replay run's normalized persisted log directly against normalized `session.jsonl`. Keep explicit `session.golden.jsonl` only for authored scenarios where `replay.override.json` drives behavior that is not derivable from the fixture, or where the expected persisted log intentionally differs from the replay script. Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. ## Acceptance criteria -- Recorded scenarios stop committing `session.golden.jsonl`. +- Recorded scenarios commit `session.jsonl` as the single session-log fixture/golden and stop committing `session.golden.jsonl`. - The snapshot test derives the expected session log from `session.jsonl` for `recorded: true` scenarios. - Authored sidecar scenarios keep explicit session goldens when needed. - Orphan-fixture guards understand which files are required by scenario kind. diff --git a/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md similarity index 85% rename from docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md rename to docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md index ecbd06a734..173b63e8aa 100644 --- a/docs/rfc/proposed/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md @@ -1,6 +1,6 @@ # RFC: Persist assembled assistant messages, not stream chunks -Status: proposed +Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. ## Problem @@ -10,7 +10,7 @@ For successful steps that assemble completed content, the loop already appends a ## Proposal -Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. Scenarios that need partial failed-stream output must record that output in the replay fixture or accept that it is not part of completed conversation history. +Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. Scenarios that need partial failed-stream output must record that output in the replay fixture. ACP `session/load` can replay prior assistant messages as complete content blocks instead of simulating the original token stream. A loaded transcript need not reproduce every historical delta; it must show the same completed assistant content and resume with a valid provider history. @@ -25,7 +25,7 @@ ACP `session/load` can replay prior assistant messages as complete content block ## What we give up -The canonical user session no longer reconstructs the exact token stream of an old turn. It also loses partial assistant output from failed or aborted streams unless another event or fixture records it. That is acceptable for resume and load, where completed message content is the user-visible state. Tests that need exact deterministic streams should own that fixture directly instead of smuggling it through the durable session format. +The canonical user session no longer reconstructs the exact token stream of an old turn. It also loses partial assistant output from failed or aborted streams unless another event or fixture records it. That is too much information loss for the current resume, load, and snapshot contracts. Tests that need exact deterministic streams should own that fixture directly only if the production session log keeps enough fidelity for user-visible recovery. ## Related diff --git a/docs/rfc/proposed/2026-06-20-classify-support-packages.md b/docs/rfc/rejected/2026-06-20-classify-support-packages.md similarity index 82% rename from docs/rfc/proposed/2026-06-20-classify-support-packages.md rename to docs/rfc/rejected/2026-06-20-classify-support-packages.md index c3947c1c61..3634974a5b 100644 --- a/docs/rfc/proposed/2026-06-20-classify-support-packages.md +++ b/docs/rfc/rejected/2026-06-20-classify-support-packages.md @@ -1,6 +1,6 @@ # RFC: Classify product, integration, and support packages -Status: proposed +Status: rejected — a single product/support taxonomy is too coarse. If package metadata changes, it should be aspect-oriented (`core`, `bash`, `fs`, `persistence`, `example`, `testing`, `publishable`, and similar facets) instead of forcing every package into one hierarchy. ## Problem @@ -12,6 +12,8 @@ This is not just cosmetic. A package's location currently says little about whet Introduce an explicit package classification and move packages accordingly, for example `packages/core/`, `packages/integrations/`, `packages/tools/`, `packages/testing/`, and `packages/examples/`, or an equivalent structure decided in the implementing PR. The important part is that example/test support packages are not indistinguishable from product core. +The rejected part is the one-dimensional taxonomy. The useful follow-up is explicit package aspect metadata that scripts can consume without pretending a package has only one role. + This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. ## Acceptance criteria diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/2026-06-20-drop-acp-session-load.md similarity index 85% rename from docs/rfc/proposed/2026-06-20-drop-acp-session-load.md rename to docs/rfc/rejected/2026-06-20-drop-acp-session-load.md index fe58449645..5714dc058f 100644 --- a/docs/rfc/proposed/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/2026-06-20-drop-acp-session-load.md @@ -1,12 +1,12 @@ # RFC: Drop ACP session/load until resume has a product shape -Status: proposed +Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. ## Problem ACP advertises `loadSession: true` and implements `session/load` by injecting persistence into the bridge, validating cwd against stored metadata, reconstructing an agent from the persisted log, and replaying prior transcript updates to the client. That path has its own race handling, loading-id guard, replay presenter logic, and tests. It also depends on the canonical log retaining enough UI data to reconstruct old chunks and tool presentations. -Durable persistence remains foundational, but editor-visible resume is not yet a designed product flow. There is no session picker, no title/preview metadata, and no clear UX for failed or partial loads. The bridge is paying complexity for a feature that is mostly exercised by tests and documentation. +Durable persistence remains foundational, but editor-visible resume is not yet a designed product flow. There is no session picker, no title/preview metadata, and no clear UX for failed or partial loads. The bridge is paying complexity for a feature that is exercised by tests, documentation, and the current target client's session model. ## Proposal diff --git a/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md similarity index 89% rename from docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md rename to docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md index 05dacc100b..89b0335275 100644 --- a/docs/rfc/proposed/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md @@ -1,12 +1,12 @@ # RFC: Drop ACP terminal `_meta` rendering -Status: proposed +Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. ## Problem The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering RFC](../implemented/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. -The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway. +The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration. ## Proposal diff --git a/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md similarity index 79% rename from docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md rename to docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md index 9f55e6e6f8..ba533bba6d 100644 --- a/docs/rfc/proposed/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md @@ -1,6 +1,6 @@ # RFC: Drop bash full-output spill files -Status: proposed +Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. ## Problem @@ -12,7 +12,7 @@ This solves a real problem, but in a narrow and leaky way. A spill path is a pro Keep tail truncation, drop full-output spill files. A bash result contains the bounded tail plus a clear truncation marker; no path is emitted. If users need full-output recovery, add a generic artifact/blob service with explicit ownership, cleanup, and UI rendering, then let bash attach large outputs to that service. -This proposal can land independently of [foreground-only bash](2026-06-20-foreground-only-bash.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path. +This proposal can land independently of [a generic long-running tool runtime](../proposed/2026-06-20-generic-long-running-tool-runtime.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path. ## Acceptance criteria diff --git a/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/2026-06-20-drop-unused-session-lineage.md similarity index 79% rename from docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md rename to docs/rfc/rejected/2026-06-20-drop-unused-session-lineage.md index 0a5d1474f7..f32b0f3461 100644 --- a/docs/rfc/proposed/2026-06-20-drop-unused-session-lineage.md +++ b/docs/rfc/rejected/2026-06-20-drop-unused-session-lineage.md @@ -1,12 +1,12 @@ # RFC: Drop unused session lineage metadata -Status: proposed +Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. ## Problem `SessionHeader.parentSession` records the session a new session was forked from. It is defined in `dsh-session`, preserved by persistence backends, copied through resume, documented as lineage metadata, and covered by round-trip tests. The repo has no production fork UI or sub-agent flow that reads it. The planned sub-agent/fork seam is still a TODO, so the field is currently stored future shape. -The cost is small per file but broad across the format: every backend schema and metadata serializer preserves a value that no feature uses. Because the header is an on-disk contract, even a placeholder field becomes something future refactors must either maintain, migrate, or deliberately break. +The cost is small per file but broad across the format: every backend schema and metadata serializer preserves a value that no completed feature reads yet. Because the header is an on-disk contract, even a placeholder field becomes something future refactors must either maintain, migrate, or deliberately break. ## Proposal diff --git a/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md similarity index 90% rename from docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md rename to docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md index bd760e7922..da19617793 100644 --- a/docs/rfc/proposed/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md @@ -1,6 +1,6 @@ # RFC: Fold the persistence interface into dsh-session -Status: proposed +Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. ## Problem diff --git a/docs/rfc/proposed/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/2026-06-20-generic-tool-rendering.md similarity index 93% rename from docs/rfc/proposed/2026-06-20-generic-tool-rendering.md rename to docs/rfc/rejected/2026-06-20-generic-tool-rendering.md index e6bde8f434..5b80920a6a 100644 --- a/docs/rfc/proposed/2026-06-20-generic-tool-rendering.md +++ b/docs/rfc/rejected/2026-06-20-generic-tool-rendering.md @@ -1,6 +1,6 @@ # RFC: Collapse tool-owned UI presentation -Status: proposed +Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. ## Problem diff --git a/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md similarity index 87% rename from docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md rename to docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md index 45092fc1e6..cbfa238370 100644 --- a/docs/rfc/proposed/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md @@ -1,6 +1,6 @@ # RFC: Retire mid-turn steering -Status: proposed +Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. ## Problem @@ -30,4 +30,4 @@ A user cannot add same-turn steering content while a model is between tool steps ## Related -This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps. +This pairs naturally with [dropping durable step boundaries](../proposed/2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps. diff --git a/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md similarity index 64% rename from docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md rename to docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md index 0558975949..ab26b9c3c9 100644 --- a/docs/rfc/proposed/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md @@ -1,12 +1,12 @@ # RFC: Return the ACP bridge to one live session per connection -Status: proposed +Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. ## Problem -The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. +The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](../proposed/2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. -The product has not yet proven it needs concurrent editor conversations over one harness process. The snapshot replay tier also avoids concurrent model streams because its replay entries are positional; concurrency would require keying replay by request instead of by stream order. +The product target has proven it needs concurrent editor conversations over one harness process: Zed's ACP connection owns multiple sessions and load states. The snapshot replay tier still avoids concurrent model streams because its replay entries are positional; that is a test-fixture limitation, not a reason to remove bridge multiplexing. ## Proposal @@ -20,7 +20,7 @@ Remove the multi-session maps and demux where a single `SessionRecord | undefine - `session/new` and `session/load` reject while that record exists. - Event handlers no longer demux across a `Map`. - Multi-session tests are removed or moved under the proposal that continues to defend multiplexing. -- The existing [multi-session ACP proposal](2026-06-14-acp-multi-session.md) is updated to link this RFC while both proposals remain live. +- The existing [multi-session ACP proposal](../proposed/2026-06-14-acp-multi-session.md) is updated to link this RFC and remains the live direction. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md similarity index 90% rename from docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md rename to docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md index 388af09716..410237daa4 100644 --- a/docs/rfc/proposed/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md @@ -1,6 +1,6 @@ # RFC: Truncate interrupted final turns on load -Status: proposed +Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. ## Problem @@ -29,4 +29,4 @@ A crash can lose real work from the final turn: assistant text, tool calls, and ## Related -This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. +This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](../proposed/2026-06-20-drop-durable-step-boundaries.md) smaller. diff --git a/packages/acp/README.md b/packages/acp/README.md index 23f32b9bd4..2c6d531a60 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. From 7f2769c529b10a6d174447555e02aac29666e0b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:07:25 +0800 Subject: [PATCH 42/87] docs: address latest simplification review --- docs/rfc/README.md | 5 +- .../2026-06-20-classify-packages-by-aspect.md | 46 +++++++++++++++++++ .../2026-06-20-discover-package-inventory.md | 2 +- ...0-remove-redundant-snapshot-log-goldens.md | 20 ++++---- .../2026-06-20-classify-support-packages.md | 2 +- ...2026-06-20-drop-durable-step-boundaries.md | 6 +-- .../2026-06-20-retire-mid-turn-steering.md | 2 +- .../2026-06-20-truncate-interrupted-turns.md | 2 +- 8 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md rename docs/rfc/{proposed => rejected}/2026-06-20-drop-durable-step-boundaries.md (67%) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 84677bda8a..6e2fbb9a7e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -37,11 +37,11 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop unconsumed assembled LLM convenience surfaces](proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | | [Drop the unconsumed `llm/adapter-change` event](proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | | [Prune dead methods from the persistence and bash seams](proposed/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | -| [Drop durable step boundary events](proposed/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](proposed/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | | [Extract a generic long-running tool runtime](proposed/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/2026-06-20-providerless-example-base.md) | 2026-06-20 | -| [Use the recorded session fixture as the snapshot log golden](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Use `session.jsonl` as the only snapshot session-log artifact](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Classify packages by aspect metadata](proposed/2026-06-20-classify-packages-by-aspect.md) | 2026-06-20 | | [Discover package inventories instead of maintaining static lists](proposed/2026-06-20-discover-package-inventory.md) | 2026-06-20 | ## Implemented @@ -84,6 +84,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop ACP session/load until resume has a product shape](rejected/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | | [Drop ACP terminal `_meta` rendering](rejected/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | | [Drop bash full-output spill files](rejected/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | +| [Drop durable step boundary events](rejected/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | | [Drop unused session lineage metadata](rejected/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | | [Fold the persistence interface into dsh-session](rejected/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | | [Collapse tool-owned UI presentation](rejected/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | diff --git a/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md b/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md new file mode 100644 index 0000000000..e9723a5660 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md @@ -0,0 +1,46 @@ +# RFC: Classify packages by aspect metadata + +Status: proposed + +## Problem + +The harness package tree is flat, and every package manifest is currently `private: true`. That is fine as a pre-release safety default, but it means neither paths nor npm publish flags tell scripts what role a package plays. [publint-all](../../../scripts/publint-all.ts) needs to know which packages are release-shaped, docs need to describe which packages are core product surface, and future cleanup work needs a way to distinguish support utilities from load-bearing product modules. + +A single hierarchy such as product, integration, support, or testing is too coarse. Packages naturally carry overlapping facts: an LLM adapter is provider-facing and publish-shaped; `tool-bash` is a tool consumer and bash-related; `llm-replay` is an LLM adapter shape and test/snapshot support; ACP is an editor bridge and current product surface. Forcing each package into one bucket would either hide useful facts or recreate static exception lists under different names. + +## Proposal + +Add explicit, repo-owned package aspect metadata to each `packages/*/package.json`, using a manifest-local key such as `dsh.aspects` unless the implementing change finds an already-established repo metadata key. The metadata is a controlled vocabulary, not free-form prose. + +For example: + +```json +{ + "dsh": { + "aspects": ["core", "llm", "publishable"] + } +} +``` + +The initial vocabulary should stay small and useful to scripts. Expected facets include `core`, `implementation`, `consumer`, `llm`, `bash`, `fs`, `persistence`, `agent`, `acp`, `ui`, `example-support`, `test-support`, `replay`, and `publishable`. A package may declare multiple facets; no script should assume exactly one role. + +`publishable` is a repo policy facet, not a mirror of npm's `private` flag. While the harness is unreleased, packages can remain `private: true` and still declare `publishable` so publish-shape gates know which manifests to check. When release policy changes, the aspect continues to describe intent while the npm flag controls whether publication is allowed. + +Scripts should consume the metadata directly. `publint-all` filters on `publishable`, module graph or package inventory docs can group by domain facets, and the adding-a-package cookbook asks authors to choose aspects when creating a new package. Unknown facets should fail loudly so typoed metadata does not silently fork the taxonomy. + +## Acceptance criteria + +- Every `packages/*` manifest declares package aspects from a documented controlled vocabulary. +- The vocabulary explains each facet's meaning and when a new facet is appropriate. +- `publint-all` derives its package list from `publishable` metadata instead of a hard-coded array. +- Package inventory docs and module-graph grouping can read aspects without inferring intent from package names or folder paths. +- Adding a package requires choosing aspects, and CI fails if a package is missing aspect metadata or uses an unknown facet. +- No package path moves are required just to express classification. + +## What we give up + +Aspect metadata is less visually obvious than folders, and a package can be over-tagged if reviewers are careless. The counterweight is that metadata preserves the current package import shape while making policy facts explicit and machine-checkable. If a future package truly needs a new physical boundary, that move can still happen for architectural reasons rather than as a classification workaround. + +## Related + +This supersedes the rejected [product/integration/support package taxonomy](../rejected/2026-06-20-classify-support-packages.md) and supplies the package source of truth expected by [discover package inventories](2026-06-20-discover-package-inventory.md). diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md index c75f93fd99..c99412c14b 100644 --- a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md @@ -12,7 +12,7 @@ Static lists are appropriate when they encode policy; they are needless friction Make package/gate inventories discoverable. Publishability should come from explicit package aspect metadata, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. -The metadata should be aspect-oriented rather than a single support/product bucket: a package may be core, bash-related, filesystem-related, persistence-related, provider-facing, example-facing, testing-only, and/or publishable. Discovery needs enough explicit facts to drive gates without baking a fragile hierarchy into every script. +The metadata should come from [classifying packages by aspect](2026-06-20-classify-packages-by-aspect.md) rather than a single support/product bucket: a package may be core, bash-related, filesystem-related, persistence-related, provider-facing, example-facing, testing-only, and/or publishable. Discovery needs enough explicit facts to drive gates without baking a fragile hierarchy into every script. ## Acceptance criteria diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md index cf853ce341..cb40dbbf9f 100644 --- a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,27 +1,31 @@ -# RFC: Use the recorded session fixture as the snapshot log golden +# RFC: Use `session.jsonl` as the only snapshot session-log artifact Status: proposed ## Problem -Recorded ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. +Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -The duplicate file can help review by showing "expected persisted log" separately from "model replay input", but for recorded scenarios those are intentionally the same artifact. Keeping both means a re-record churns two files with the same semantic content, when one committed session log can serve as both replay input and expected persisted output. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Proposal -For recorded scenarios, keep one session-log artifact: `session.jsonl`. The snapshot test compares the replay run's normalized persisted log directly against normalized `session.jsonl`. Keep explicit `session.golden.jsonl` only for authored scenarios where `replay.override.json` drives behavior that is not derivable from the fixture, or where the expected persisted log intentionally differs from the replay script. +Remove the `session.golden.jsonl` concept entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`: + +- For recorded scenarios, `session.jsonl` remains the raw harvested log. Replay still derives model chunks from it, and the snapshot test compares the replay run's normalized persisted log against normalized `session.jsonl`. +- For authored override scenarios, `replay.override.json` drives model behavior and `session.jsonl` holds the expected produced session log. The replay adapter ignores the fixture for model chunks when the override exists, so the same file can be the expected log without affecting replay behavior. +- For no-model scenarios, `session.jsonl` can stay as the minimal fixture needed to boot `llm-replay`; no session-log comparison is needed unless the scenario creates a persisted session. Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. ## Acceptance criteria -- Recorded scenarios commit `session.jsonl` as the single session-log fixture/golden and stop committing `session.golden.jsonl`. -- The snapshot test derives the expected session log from `session.jsonl` for `recorded: true` scenarios. -- Authored sidecar scenarios keep explicit session goldens when needed. +- `session.golden.jsonl` disappears from the snapshot harness, fixtures, orphan guards, and docs. +- The snapshot test derives the expected session log from `session.jsonl` for every model scenario. +- Authored sidecar scenarios commit their expected produced log in `session.jsonl`; `replay.override.json` remains the model-behavior override. - Orphan-fixture guards understand which files are required by scenario kind. - The [ACP snapshot tests RFC](../implemented/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set. ## What we give up -Reviewers lose one redundant artifact that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to the recorded fixture preserves the loop/persistence regression check without duplicating files. +Reviewers lose one artifact name that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to `session.jsonl` preserves the loop/persistence regression check without duplicating files. diff --git a/docs/rfc/rejected/2026-06-20-classify-support-packages.md b/docs/rfc/rejected/2026-06-20-classify-support-packages.md index 3634974a5b..254ddbdc7c 100644 --- a/docs/rfc/rejected/2026-06-20-classify-support-packages.md +++ b/docs/rfc/rejected/2026-06-20-classify-support-packages.md @@ -12,7 +12,7 @@ This is not just cosmetic. A package's location currently says little about whet Introduce an explicit package classification and move packages accordingly, for example `packages/core/`, `packages/integrations/`, `packages/tools/`, `packages/testing/`, and `packages/examples/`, or an equivalent structure decided in the implementing PR. The important part is that example/test support packages are not indistinguishable from product core. -The rejected part is the one-dimensional taxonomy. The useful follow-up is explicit package aspect metadata that scripts can consume without pretending a package has only one role. +The rejected part is the one-dimensional taxonomy. The useful follow-up is [explicit package aspect metadata](../proposed/2026-06-20-classify-packages-by-aspect.md) that scripts can consume without pretending a package has only one role. This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. diff --git a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md similarity index 67% rename from docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md rename to docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md index ad424cb06e..60b2af391a 100644 --- a/docs/rfc/proposed/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md @@ -1,12 +1,12 @@ # RFC: Drop durable step boundary events -Status: proposed +Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. ## Problem The session log stores `step/start` and `step/end` events even though every step-scoped event already carries `{ turn, step }`: assistant chunks, assistant messages, tool calls, tool results, usage, and errors. `deriveMessages()` ignores step boundaries, ACP ignores them for UI, and the main consumers are invariants, tests, snapshot goldens, and crash repair. -The boundary events make the log more ceremonial than informative. The loop tracks open steps solely to close them, repair synthesizes `step/end` when a crash leaves a step open, invariants track a second nesting stack inside the turn, and snapshots carry lines that do not affect replayed message history. A model request that crashes before producing any step-scoped event is the only information represented by a bare `step/start`, and that case has no useful resumable content. +The rejected argument was that boundary events make the log more ceremonial than informative. In practice, `step/end` is concrete information: a reader can tell whether a model request finished, crashed, or is being repaired without deriving that state from the next event. A bare `step/start` is likewise useful for a model request that began but produced no chunks before failing. ## Proposal @@ -25,4 +25,4 @@ The invariants plugin should enforce that step-scoped events have valid positive ## What we give up -The log no longer records "a model request started but produced no event before the process died" as a durable fact. That is acceptable: there is no assistant content, tool call, usage, or error to replay from that empty request. A live UI can still show an in-progress step from a transient event if it needs one; the durable log should not store an empty bracket. +The log no longer records "a model request started but produced no event before the process died" as a durable fact, and no longer has an explicit "this step completed" marker. That loss is not acceptable while the session log is the durable replay and audit surface. diff --git a/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md index cbfa238370..97c7d5dc61 100644 --- a/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md @@ -30,4 +30,4 @@ A user cannot add same-turn steering content while a model is between tool steps ## Related -This pairs naturally with [dropping durable step boundaries](../proposed/2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps. +This pairs naturally with [dropping durable step boundaries](2026-06-20-drop-durable-step-boundaries.md), because removing same-turn steering and `agent/turn-continuation` leaves tool calls as the only reason a turn contains multiple model steps. diff --git a/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md index 410237daa4..771388ede9 100644 --- a/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md @@ -29,4 +29,4 @@ A crash can lose real work from the final turn: assistant text, tool calls, and ## Related -This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](../proposed/2026-06-20-drop-durable-step-boundaries.md) smaller. +This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. From 800e08e9308ea558e96bf58069e7e2133198627e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:28:02 +0800 Subject: [PATCH 43/87] docs: refine package hierarchy RFC --- docs/rfc/README.md | 3 +- .../2026-06-20-classify-packages-by-aspect.md | 46 --------------- .../2026-06-20-discover-package-inventory.md | 8 +-- .../proposed/2026-06-20-package-hierarchy.md | 57 +++++++++++++++++++ ...0-remove-redundant-snapshot-log-goldens.md | 2 +- .../2026-06-20-classify-support-packages.md | 28 --------- scripts/publint-all.ts | 2 +- 7 files changed, 64 insertions(+), 82 deletions(-) delete mode 100644 docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md create mode 100644 docs/rfc/proposed/2026-06-20-package-hierarchy.md delete mode 100644 docs/rfc/rejected/2026-06-20-classify-support-packages.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6e2fbb9a7e..7098ea216e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -41,7 +41,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Use `session.jsonl` as the only snapshot session-log artifact](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | -| [Classify packages by aspect metadata](proposed/2026-06-20-classify-packages-by-aspect.md) | 2026-06-20 | +| [Reorganize packages into a modular hierarchy](proposed/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Discover package inventories instead of maintaining static lists](proposed/2026-06-20-discover-package-inventory.md) | 2026-06-20 | ## Implemented @@ -80,7 +80,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Deep-readonly public surfaces](rejected/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | | [Persist assembled assistant messages, not stream chunks](rejected/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | -| [Classify product, integration, and support packages](rejected/2026-06-20-classify-support-packages.md) | 2026-06-20 | | [Drop ACP session/load until resume has a product shape](rejected/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | | [Drop ACP terminal `_meta` rendering](rejected/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | | [Drop bash full-output spill files](rejected/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | diff --git a/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md b/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md deleted file mode 100644 index e9723a5660..0000000000 --- a/docs/rfc/proposed/2026-06-20-classify-packages-by-aspect.md +++ /dev/null @@ -1,46 +0,0 @@ -# RFC: Classify packages by aspect metadata - -Status: proposed - -## Problem - -The harness package tree is flat, and every package manifest is currently `private: true`. That is fine as a pre-release safety default, but it means neither paths nor npm publish flags tell scripts what role a package plays. [publint-all](../../../scripts/publint-all.ts) needs to know which packages are release-shaped, docs need to describe which packages are core product surface, and future cleanup work needs a way to distinguish support utilities from load-bearing product modules. - -A single hierarchy such as product, integration, support, or testing is too coarse. Packages naturally carry overlapping facts: an LLM adapter is provider-facing and publish-shaped; `tool-bash` is a tool consumer and bash-related; `llm-replay` is an LLM adapter shape and test/snapshot support; ACP is an editor bridge and current product surface. Forcing each package into one bucket would either hide useful facts or recreate static exception lists under different names. - -## Proposal - -Add explicit, repo-owned package aspect metadata to each `packages/*/package.json`, using a manifest-local key such as `dsh.aspects` unless the implementing change finds an already-established repo metadata key. The metadata is a controlled vocabulary, not free-form prose. - -For example: - -```json -{ - "dsh": { - "aspects": ["core", "llm", "publishable"] - } -} -``` - -The initial vocabulary should stay small and useful to scripts. Expected facets include `core`, `implementation`, `consumer`, `llm`, `bash`, `fs`, `persistence`, `agent`, `acp`, `ui`, `example-support`, `test-support`, `replay`, and `publishable`. A package may declare multiple facets; no script should assume exactly one role. - -`publishable` is a repo policy facet, not a mirror of npm's `private` flag. While the harness is unreleased, packages can remain `private: true` and still declare `publishable` so publish-shape gates know which manifests to check. When release policy changes, the aspect continues to describe intent while the npm flag controls whether publication is allowed. - -Scripts should consume the metadata directly. `publint-all` filters on `publishable`, module graph or package inventory docs can group by domain facets, and the adding-a-package cookbook asks authors to choose aspects when creating a new package. Unknown facets should fail loudly so typoed metadata does not silently fork the taxonomy. - -## Acceptance criteria - -- Every `packages/*` manifest declares package aspects from a documented controlled vocabulary. -- The vocabulary explains each facet's meaning and when a new facet is appropriate. -- `publint-all` derives its package list from `publishable` metadata instead of a hard-coded array. -- Package inventory docs and module-graph grouping can read aspects without inferring intent from package names or folder paths. -- Adding a package requires choosing aspects, and CI fails if a package is missing aspect metadata or uses an unknown facet. -- No package path moves are required just to express classification. - -## What we give up - -Aspect metadata is less visually obvious than folders, and a package can be over-tagged if reviewers are careless. The counterweight is that metadata preserves the current package import shape while making policy facts explicit and machine-checkable. If a future package truly needs a new physical boundary, that move can still happen for architectural reasons rather than as a classification workaround. - -## Related - -This supersedes the rejected [product/integration/support package taxonomy](../rejected/2026-06-20-classify-support-packages.md) and supplies the package source of truth expected by [discover package inventories](2026-06-20-discover-package-inventory.md). diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md index c99412c14b..bb2f3e95ee 100644 --- a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/2026-06-20-discover-package-inventory.md @@ -6,17 +6,17 @@ Status: proposed Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../.github/workflows/ci.yml) and [development docs](../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. -Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data that already exists in `package.json`, workspace globs, or package metadata. +Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make package/gate inventories discoverable. Publishability should come from explicit package aspect metadata, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make package/gate inventories discoverable. Publishability should come from the deliberate [package hierarchy](2026-06-20-package-hierarchy.md) plus package manifests, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. -The metadata should come from [classifying packages by aspect](2026-06-20-classify-packages-by-aspect.md) rather than a single support/product bucket: a package may be core, bash-related, filesystem-related, persistence-related, provider-facing, example-facing, testing-only, and/or publishable. Discovery needs enough explicit facts to drive gates without baking a fragile hierarchy into every script. +The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. ## Acceptance criteria -- `publint-all` discovers publishable packages from manifests plus explicit aspect metadata. +- `publint-all` discovers publishable packages from the hierarchy plus manifests instead of a hard-coded array. - Adding a package does not require editing a static package list for every gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/docs/rfc/proposed/2026-06-20-package-hierarchy.md b/docs/rfc/proposed/2026-06-20-package-hierarchy.md new file mode 100644 index 0000000000..bc0c401ca2 --- /dev/null +++ b/docs/rfc/proposed/2026-06-20-package-hierarchy.md @@ -0,0 +1,57 @@ +# RFC: Reorganize packages into a modular hierarchy + +Status: proposed + +## Problem + +`packages/` is flat. Core product packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all sit at the same level and look equally foundational. The [package README](../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more product-shaped than they are and forces publish/lint/doc scripts to encode intent through comments or static lists. + +This is not just cosmetic. A package's location currently says little about whether it is core API, a swappable capability, an adapter integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. + +## Proposal + +Move packages into a deliberate hierarchy under `packages/`. The exact layout is deferred to the implementing PR, but it should group packages by modular role rather than keep every package at one flat level. + +One plausible shape: + +```text +packages/ + core/ + llm/ + session/ + system-prompt/ + tools/ + agent/ + agent-loop/ + invariants/ + capabilities/ + bash/ + bash-local/ + tool-bash/ + session-persistence/ + session-persistence-jsonl/ + session-persistence-sqlite/ + integrations/ + llm-deepseek/ + llm-pi-ai/ + acp/ + support/ + ui-stdio/ + llm-replay/ +``` + +The final implementation may choose different names or groupings, but it should keep the same intent: core APIs, capability seams, concrete integrations, and support/test/example packages are distinguishable from the filesystem alone. Npm package names can stay `@deepseek-ai/dsh-*`; the hierarchy is about repo structure and maintenance policy, not public package renaming. + +This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. + +## Acceptance criteria + +- Packages move from the flat `packages//` layout into a documented modular hierarchy. +- The implementing PR chooses the exact hierarchy and updates workspace globs, TypeScript paths, package docs, generated module graphs, `cordis.yml` package paths, build scripts, and publish/lint scripts in one coordinated move. +- Scripts that publish, lint publishability, or generate package inventories use the hierarchy instead of an ad hoc static list where the hierarchy is enough to express the policy. +- Docs explain which package groups are part of the product API and which groups are support/test/example infrastructure. +- New package guidance tells authors where to place a package and discourages new one-off top-level groups. + +## What we give up + +The restructure churns imports, workspace globs, docs links, and package paths. That churn is acceptable pre-release if it prevents the flat layout from fossilizing support packages as product contracts. diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md index cb40dbbf9f..15b996cf61 100644 --- a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -6,7 +6,7 @@ Status: proposed Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Proposal diff --git a/docs/rfc/rejected/2026-06-20-classify-support-packages.md b/docs/rfc/rejected/2026-06-20-classify-support-packages.md deleted file mode 100644 index 254ddbdc7c..0000000000 --- a/docs/rfc/rejected/2026-06-20-classify-support-packages.md +++ /dev/null @@ -1,28 +0,0 @@ -# RFC: Classify product, integration, and support packages - -Status: rejected — a single product/support taxonomy is too coarse. If package metadata changes, it should be aspect-oriented (`core`, `bash`, `fs`, `persistence`, `example`, `testing`, `publishable`, and similar facets) instead of forcing every package into one hierarchy. - -## Problem - -`packages/` is flat. Core product packages, provider integrations, tool implementations, example UI support, and snapshot-only replay support all sit at the same level and look equally publishable. The [package README](../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more foundational than they are and forces publish/lint/doc scripts to special-case intent in prose or static lists. - -This is not just cosmetic. A package's location currently says little about whether it is core API, an integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. - -## Proposal - -Introduce an explicit package classification and move packages accordingly, for example `packages/core/`, `packages/integrations/`, `packages/tools/`, `packages/testing/`, and `packages/examples/`, or an equivalent structure decided in the implementing PR. The important part is that example/test support packages are not indistinguishable from product core. - -The rejected part is the one-dimensional taxonomy. The useful follow-up is [explicit package aspect metadata](../proposed/2026-06-20-classify-packages-by-aspect.md) that scripts can consume without pretending a package has only one role. - -This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. - -## Acceptance criteria - -- Each package has an explicit classification visible from path or package metadata. -- Scripts that publish, lint publishability, or generate module graphs use the classification instead of an ad hoc static list. -- Docs explain which package classes are part of the product API. -- YAML loader paths and TypeScript path aliases are updated in one coordinated move. - -## What we give up - -The restructure churns imports, workspace globs, docs links, and package paths. That churn is acceptable pre-release if it prevents the flat layout from fossilizing a support package as a product contract. diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 9d439fba98..e0bee4393e 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' // publint every publishable package (vendor/ is private upstream code and // examples/ are not packages; both are out of scope). -// TODO(package-inventory): derive this from explicit package classification metadata. +// TODO(package-inventory): derive this from the deliberate package hierarchy. const packages = [ 'packages/llm', 'packages/session', From d614f3aabfec36df767278d868c26af1a86d4989 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:34:04 +0800 Subject: [PATCH 44/87] docs(rfc): record the core-data-structures catalog + type-equiv decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #71 shipped the core-data-structures catalog and the verify-type-equiv drift gate without an RFC (judged small at the time). Add the retroactive implemented RFC so the sibling pair is documented symmetrically: the spine-vs-seam scoping rule (discovered by testing candidate definitions against borderline types like BashExecRequest and ToolDefinition), the verbatim-match-over-assignability choice for the gate, and the process — including the Codex-caught scan-gap bug fixed in 6da7a0f. Cross-link the two catalog RFCs to each other and index the new one. --- docs/rfc/README.md | 1 + ...2026-06-20-core-data-structures-catalog.md | 56 +++++++++++++++++++ .../2026-06-20-generated-cordis-catalog.md | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/2026-06-20-core-data-structures-catalog.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 5b53345345..207afd1fdb 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -62,6 +62,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | | [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | +| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/2026-06-20-core-data-structures-catalog.md new file mode 100644 index 0000000000..5d4209b732 --- /dev/null +++ b/docs/rfc/implemented/2026-06-20-core-data-structures-catalog.md @@ -0,0 +1,56 @@ +# RFC: Core-data-structures catalog and the `ts type-equiv` drift gate + +Status: implemented (accepted 2026-06-20) + + + +## Context + +A reader trying to understand the harness could find its *behavior* in [architecture.md](../../architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `SessionEvent`, a `StreamChunk`" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it. + +So the work had two intertwined questions: **what belongs in such a catalog** (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and **how to keep pasted type definitions from drifting** (the durability problem). This RFC records both decisions. Its sibling, [the generated cordis events + services catalog](2026-06-20-generated-cordis-catalog.md), is the *wiring*-axis complement: this one catalogs the data structures, that one the events and services that move them. + +## Decision + +A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type definition byte-identical to its source. + +### What counts as "core" — the spine-vs-seam line + +The scoping line was not picked top-down; it was discovered by testing candidate definitions against concrete borderline types until one rule survived every case. The decisive test was `BashExecRequest`/`BashExecSpec`/`BashRunResult`: bash is a capability *seam*, not part of the agent-loop spine, so if those are "core" then "core" means *all cross-package vocabulary* and the catalog is a flat dump; if they are not, "core" means *the central spine* and bash vocabulary belongs on a sub-page. The latter won, which set the whole structure: a **tiered folder**, not a flat document. + +The rule that settled the remaining cases: ***the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.*** Worked through: + +- A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`). +- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp. +- `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict. +- The tool-presentation vocabulary (`ToolCallPresentation`, …, carrying a `FIXME(tool-presentation)` redesign marker), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages. + +`core.md` is a **self-contained spine doc**: it states the exact type definition of each spine structure with minimal prose and links to sub-pages for the per-seam detail. The sub-pages are `llm-streaming.md`, `session.md`, `persistence.md` (split from session along the in-memory-model vs. durability-seam line), `tools.md`, and `bash.md`. + +### The `ts type-equiv` mechanism — literal AND drift-proof + +The durability requirement was specific: the doc should show the **literal** current type definition (so a reader sees the real shape, not a paraphrase) **and** be mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability*, not *byte-equality* — a renamed field with the same type would pass. So: + +- Type definitions are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. +- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts a **verbatim source match** against the declared symbol — chosen over a compiled `_Check` assertion precisely because byte-equality, not assignability, is the property we want. +- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. +- Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. + +### Maintenance is the author's job, with a gate backstop + +`verify-type-equiv` catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented. So AGENTS.md and the `dsh-code-review` skill were updated to require keeping the catalog in sync when a change adds or reshapes a documented type — the gate handles drift, the human handles new surface. + +## Process + +The design was driven entirely by a one-question-at-a-time grilling that walked the scoping decision tree through concrete examples (`BashExecRequest`, `ToolSchema`, `ToolDefinition`, the schema DSL, the presentation types, the session/persistence split) before committing to the spine-vs-seam rule — the rule was the *output* of the examples, not an a-priori axiom. The implementation landed as four commits mirroring the structure of the work: the gate (`e97f94b`), the catalog (`7e33c7b`), the maintenance-guard updates (`53e01a0`), and a review-fix commit (`6da7a0f`). + +That last commit is why the process is worth recording: an independent Codex review (gpt-5.5:xhigh) found a real **scan-gap bug** — `verify-type-equiv` only scanned the docs the manifest named, so a type-equiv block added to an *unmanifested* doc was silently skipped, defeating the 1:1 guarantee in one direction. The fix scans every doc in the markdown scope and reports an unmanifested block as an orphan. The same review corrected a `SessionPersistence` surface-listing prose error (`has`/`delete`) and the `doc-sync` command summary. The bug is the point: a drift gate that silently skips part of its input is worse than no gate, and only an adversarial reader caught it. + +This decision shipped in #71 **without** an RFC at the time — the judgment was that the `ts type-equiv` convention was small enough to document in `development.md`. This RFC is the retroactive record: the spine-vs-seam scoping rule and the verbatim-match-over-assignability choice are exactly the kind of "why was it done this way?" decisions a future maintainer would otherwise re-litigate, and its sibling catalog ([generated cordis events + services](2026-06-20-generated-cordis-catalog.md)) does carry an RFC, so the pair should be documented symmetrically. + +## Consequences + +- The vocabulary now has a single home that **cannot silently drift**: a field rename in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. +- The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering. +- The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment. +- Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist. diff --git a/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md index 4f2ba01fcb..ca57c2eadb 100644 --- a/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md @@ -8,7 +8,7 @@ Status: implemented (accepted 2026-06-20) A plugin author needs two reference surfaces that no single document gave them: every cordis **event** they can listen to (with its exact signature and dispatch mode) and every `ctx.` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides. -This is the wiring-axis complement to the [core-data-structures catalog](../../core-data-structures/core.md): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. +This is the wiring-axis complement to the [core-data-structures catalog](../../core-data-structures/core.md) ([its RFC](2026-06-20-core-data-structures-catalog.md)): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. ## Decision From c079b74c789c59e4dee4a74b2f4e9ad3c5e7a75c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:36:49 +0800 Subject: [PATCH 45/87] docs: clarify package hierarchy example --- .../rfc/proposed/2026-06-20-package-hierarchy.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/rfc/proposed/2026-06-20-package-hierarchy.md b/docs/rfc/proposed/2026-06-20-package-hierarchy.md index bc0c401ca2..7351cefc93 100644 --- a/docs/rfc/proposed/2026-06-20-package-hierarchy.md +++ b/docs/rfc/proposed/2026-06-20-package-hierarchy.md @@ -17,30 +17,32 @@ One plausible shape: ```text packages/ core/ - llm/ session/ system-prompt/ tools/ agent/ agent-loop/ invariants/ - capabilities/ + llm/ + llm/ + adapters/ + llm-deepseek/ + llm-pi-ai/ + bash/ bash/ bash-local/ tool-bash/ + session-persistence/ session-persistence/ session-persistence-jsonl/ session-persistence-sqlite/ - integrations/ - llm-deepseek/ - llm-pi-ai/ - acp/ + acp/ support/ ui-stdio/ llm-replay/ ``` -The final implementation may choose different names or groupings, but it should keep the same intent: core APIs, capability seams, concrete integrations, and support/test/example packages are distinguishable from the filesystem alone. Npm package names can stay `@deepseek-ai/dsh-*`; the hierarchy is about repo structure and maintenance policy, not public package renaming. +The final implementation may choose different names or groupings, but it should keep the same intent: core APIs, package families such as LLM/bash/session persistence, standalone integrations such as ACP, and support/test/example packages are distinguishable from the filesystem alone. Npm package names can stay `@deepseek-ai/dsh-*`; the hierarchy is about repo structure and maintenance policy, not public package renaming. This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. From 605587e79c969bd00e098dcebee510dfbb0ecd0d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:29:45 +0800 Subject: [PATCH 46/87] docs(rfc): classify RFCs by kind via path-encoded subdirectories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second axis to every RFC — its class (feature, bug-fix, simplification, architecture, process, testing) — encoded in the path as docs/rfc/{lifecycle}/{class}/file.md. The folder is the label, so the closed set is enforced by structure rather than a parsed field. Two new doc-sync gates back it: - verify-rfc-classification: every RFC sits in a valid class folder and the README index lists it under the matching lifecycle→class heading. - verify-doc-refs: every docs/*.md path cited in a packages|examples TS comment resolves — closes a drift class verify-md-links can't see, and catches the four comment refs this reorg moved. The README gains a Classification section explaining the taxonomy and per-class index sub-sections. A self-referential process RFC records why the scheme is path-encoded and gated. --- .agents/skills/dsh-code-review/SKILL.md | 6 +- .../skills/dsh-find-simplifications/SKILL.md | 2 +- .github/workflows/e2e.yml | 2 +- AGENTS.md | 19 +- docs/AGENTS.md | 6 +- docs/architecture.md | 6 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-vendored-package.md | 2 +- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/persistence.md | 4 +- docs/core-data-structures/session.md | 2 +- docs/rfc/README.md | 201 ++++++++++++------ .../2026-06-11-content-block-vocabulary.md | 0 .../2026-06-11-custom-schema-dsl.md | 0 ...06-11-dev-invariants-over-deep-readonly.md | 4 +- .../2026-06-11-event-sourced-sessions.md | 0 .../2026-06-11-microkernel-event-taxonomy.md | 0 .../2026-06-11-runtime-arg-validation.md | 2 +- .../2026-06-11-structured-error-taxonomy.md | 0 ...6-06-11-tool-schemas-in-prompt-assembly.md | 0 .../2026-06-13-capability-seams.md | 2 +- .../2026-06-13-twin-llm-adapters.md | 2 +- .../2026-06-14-session-persistence.md | 6 +- .../2026-06-15-turn-enclosure-invariant.md | 0 ...-18-agent-lifecycle-and-ownership-seams.md | 2 +- ...18-shared-persistence-write-coordinator.md | 0 ...6-06-18-acp-terminal-and-tool-rendering.md | 2 +- .../2026-06-11-doc-sync-enforcement.md | 2 +- .../{ => process}/2026-06-11-quality-gates.md | 2 +- .../2026-06-11-tsdown-over-dumble.md | 0 .../2026-06-11-vendor-cordis-as-source.md | 0 .../2026-06-16-pnpm-over-yarn.md | 0 .../2026-06-18-markdown-cross-link-lint.md | 2 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- .../2026-06-20-generated-cordis-catalog.md | 4 +- .../process/2026-06-20-rfc-classification.md | 46 ++++ ...2026-06-19-drop-mutable-session-summary.md | 8 +- .../2026-06-11-property-based-testing.md | 2 +- .../2026-06-19-acp-snapshot-tests.md | 16 +- .../2026-06-19-real-api-e2e-ci.md | 8 +- .../2026-06-20-providerless-example-base.md | 27 --- .../2026-06-20-prune-dead-seam-methods.md | 49 ----- .../2026-06-16-typed-event-schemas.md | 6 +- ...06-20-generic-long-running-tool-runtime.md | 4 +- .../2026-06-20-package-hierarchy.md | 2 +- .../2026-06-20-providerless-example-base.md | 27 +++ .../2026-06-14-acp-agent-client-protocol.md | 26 +-- .../2026-06-14-acp-multi-session.md | 6 +- .../2026-06-15-optional-code-mode.md | 20 +- .../2026-06-11-api-extractor-reports.md | 2 +- .../2026-06-11-architectural-conformance.md | 4 +- ...026-06-11-supply-chain-and-vendor-drift.md | 2 +- .../2026-06-20-discover-package-inventory.md | 4 +- ...6-20-collapse-trace-only-session-events.md | 0 ...rop-unconsumed-llm-adapter-change-event.md | 6 +- ...-drop-unconsumed-llm-assembled-surfaces.md | 20 +- .../2026-06-20-prune-dead-seam-methods.md | 49 +++++ .../2026-06-20-public-agent-stop-surface.md | 0 ...-20-remove-agent-boundary-mirror-events.md | 0 .../2026-06-20-unify-agent-and-session-id.md | 2 +- ...-06-11-deterministic-and-stress-testing.md | 0 .../2026-06-11-mutation-testing.md | 2 +- ...0-remove-redundant-snapshot-log-goldens.md | 2 +- .../2026-06-11-immutable-public-surfaces.md | 8 +- ...06-20-assembled-assistant-messages-only.md | 6 +- .../2026-06-20-drop-acp-session-load.md | 2 +- .../2026-06-20-drop-acp-terminal-meta.md | 4 +- ...2026-06-20-drop-bash-output-spill-files.md | 4 +- ...2026-06-20-drop-durable-step-boundaries.md | 2 +- .../2026-06-20-drop-unused-session-lineage.md | 0 ...6-20-fold-session-persistence-interface.md | 4 +- .../2026-06-20-generic-tool-rendering.md | 0 .../2026-06-20-retire-mid-turn-steering.md | 0 .../2026-06-20-single-session-acp-bridge.md | 4 +- .../2026-06-20-truncate-interrupted-turns.md | 4 +- examples/acp-agent/tests/snapshot-harness.ts | 2 +- .../acp-agent/tests/snapshot-normalize.ts | 2 +- package.json | 4 +- packages/README.md | 2 +- packages/acp/README.md | 10 +- packages/acp/acp-feature-support.md | 4 +- packages/agent-loop/README.md | 2 +- packages/agent/README.md | 4 +- packages/invariants/README.md | 2 +- packages/llm-replay/src/index.ts | 2 +- packages/llm/README.md | 2 +- packages/session-persistence-jsonl/README.md | 2 +- packages/session-persistence-sqlite/README.md | 2 +- packages/session-persistence/README.md | 4 +- .../session-persistence/src/coordinator.ts | 2 +- scripts/verify-doc-refs.ts | 96 +++++++++ scripts/verify-rfc-classification.ts | 160 ++++++++++++++ 92 files changed, 673 insertions(+), 293 deletions(-) rename docs/rfc/implemented/{ => architecture}/2026-06-11-content-block-vocabulary.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-custom-schema-dsl.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-dev-invariants-over-deep-readonly.md (86%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-event-sourced-sessions.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-microkernel-event-taxonomy.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-runtime-arg-validation.md (90%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-structured-error-taxonomy.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-11-tool-schemas-in-prompt-assembly.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-13-capability-seams.md (90%) rename docs/rfc/implemented/{ => architecture}/2026-06-13-twin-llm-adapters.md (93%) rename docs/rfc/implemented/{ => architecture}/2026-06-14-session-persistence.md (89%) rename docs/rfc/implemented/{ => architecture}/2026-06-15-turn-enclosure-invariant.md (100%) rename docs/rfc/implemented/{ => architecture}/2026-06-18-agent-lifecycle-and-ownership-seams.md (95%) rename docs/rfc/implemented/{ => architecture}/2026-06-18-shared-persistence-write-coordinator.md (100%) rename docs/rfc/implemented/{ => feature}/2026-06-18-acp-terminal-and-tool-rendering.md (95%) rename docs/rfc/implemented/{ => process}/2026-06-11-doc-sync-enforcement.md (93%) rename docs/rfc/implemented/{ => process}/2026-06-11-quality-gates.md (95%) rename docs/rfc/implemented/{ => process}/2026-06-11-tsdown-over-dumble.md (100%) rename docs/rfc/implemented/{ => process}/2026-06-11-vendor-cordis-as-source.md (100%) rename docs/rfc/implemented/{ => process}/2026-06-16-pnpm-over-yarn.md (100%) rename docs/rfc/implemented/{ => process}/2026-06-18-markdown-cross-link-lint.md (97%) rename docs/rfc/implemented/{ => process}/2026-06-20-core-data-structures-catalog.md (92%) rename docs/rfc/implemented/{ => process}/2026-06-20-generated-cordis-catalog.md (95%) create mode 100644 docs/rfc/implemented/process/2026-06-20-rfc-classification.md rename docs/rfc/implemented/{ => simplification}/2026-06-19-drop-mutable-session-summary.md (63%) rename docs/rfc/implemented/{ => testing}/2026-06-11-property-based-testing.md (92%) rename docs/rfc/implemented/{ => testing}/2026-06-19-acp-snapshot-tests.md (75%) rename docs/rfc/implemented/{ => testing}/2026-06-19-real-api-e2e-ci.md (88%) delete mode 100644 docs/rfc/proposed/2026-06-20-providerless-example-base.md delete mode 100644 docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md rename docs/rfc/proposed/{ => architecture}/2026-06-16-typed-event-schemas.md (93%) rename docs/rfc/proposed/{ => architecture}/2026-06-20-generic-long-running-tool-runtime.md (85%) rename docs/rfc/proposed/{ => architecture}/2026-06-20-package-hierarchy.md (88%) create mode 100644 docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md rename docs/rfc/proposed/{ => feature}/2026-06-14-acp-agent-client-protocol.md (70%) rename docs/rfc/proposed/{ => feature}/2026-06-14-acp-multi-session.md (90%) rename docs/rfc/proposed/{ => feature}/2026-06-15-optional-code-mode.md (85%) rename docs/rfc/proposed/{ => process}/2026-06-11-api-extractor-reports.md (86%) rename docs/rfc/proposed/{ => process}/2026-06-11-architectural-conformance.md (83%) rename docs/rfc/proposed/{ => process}/2026-06-11-supply-chain-and-vendor-drift.md (79%) rename docs/rfc/proposed/{ => process}/2026-06-20-discover-package-inventory.md (59%) rename docs/rfc/proposed/{ => simplification}/2026-06-20-collapse-trace-only-session-events.md (100%) rename docs/rfc/proposed/{ => simplification}/2026-06-20-drop-unconsumed-llm-adapter-change-event.md (83%) rename docs/rfc/proposed/{ => simplification}/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md (61%) create mode 100644 docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md rename docs/rfc/proposed/{ => simplification}/2026-06-20-public-agent-stop-surface.md (100%) rename docs/rfc/proposed/{ => simplification}/2026-06-20-remove-agent-boundary-mirror-events.md (100%) rename docs/rfc/proposed/{ => simplification}/2026-06-20-unify-agent-and-session-id.md (97%) rename docs/rfc/proposed/{ => testing}/2026-06-11-deterministic-and-stress-testing.md (100%) rename docs/rfc/proposed/{ => testing}/2026-06-11-mutation-testing.md (79%) rename docs/rfc/proposed/{ => testing}/2026-06-20-remove-redundant-snapshot-log-goldens.md (95%) rename docs/rfc/rejected/{ => architecture}/2026-06-11-immutable-public-surfaces.md (72%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-assembled-assistant-messages-only.md (78%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-drop-acp-session-load.md (96%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-drop-acp-terminal-meta.md (75%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-drop-bash-output-spill-files.md (85%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-drop-durable-step-boundaries.md (94%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-drop-unused-session-lineage.md (100%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-fold-session-persistence-interface.md (76%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-generic-tool-rendering.md (100%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-retire-mid-turn-steering.md (100%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-single-session-acp-bridge.md (86%) rename docs/rfc/rejected/{ => simplification}/2026-06-20-truncate-interrupted-turns.md (84%) create mode 100644 scripts/verify-doc-refs.ts create mode 100644 scripts/verify-rfc-classification.ts diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index d97bb1bd77..54510133db 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -7,7 +7,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — **This skill is guidance, not a complete checklist.** It is a where-to-look map that lowers your startup cost on an unfamiliar PR — clearing every item here does not mean the PR is good. You are the reviewer: reason independently from the code in front of you, and think broadly across every dimension a change can fail on. The items below are the failure modes this repo has already paid for; a real review also catches the ones nobody has written down yet. -Independent judgment governs *what to look at* and *how to apply a rule to this case* — not whether the repo's documented requirements still hold. AGENTS.md, packages/AGENTS.md, and the [quality gates](../../../docs/rfc/implemented/2026-06-11-quality-gates.md) remain authoritative; a missing HMR-safety test or out-of-sync docs is a blocking gap regardless of your judgment, not a suggestion you can waive. Use your own reasoning to go *beyond* these checks and to weigh genuine edge cases against an RFC (raise it as a discussion, don't silently override) — never to demote a documented blocker to optional. +Independent judgment governs *what to look at* and *how to apply a rule to this case* — not whether the repo's documented requirements still hold. AGENTS.md, packages/AGENTS.md, and the [quality gates](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md) remain authoritative; a missing HMR-safety test or out-of-sync docs is a blocking gap regardless of your judgment, not a suggestion you can waive. Use your own reasoning to go *beyond* these checks and to weigh genuine edge cases against an RFC (raise it as a discussion, don't silently override) — never to demote a documented blocker to optional. ## How to think about a review @@ -25,7 +25,7 @@ These define the conventions and gates this repo is checked against, and they ar - **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). -- **[RFC index](../../../docs/rfc/README.md)** — the *why* behind the architecture. Especially [quality gates](../../../docs/rfc/implemented/2026-06-11-quality-gates.md) (what a PR must pass) and [capability seams](../../../docs/rfc/implemented/2026-06-13-capability-seams.md) (the three-package split). If a change seems to fight an RFC, that's a discussion, not a silent override — and not an automatic veto either: an RFC can be wrong for this case, so reason about it. +- **[RFC index](../../../docs/rfc/README.md)** — the *why* behind the architecture. Especially [quality gates](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md) (what a PR must pass) and [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) (the three-package split). If a change seems to fight an RFC, that's a discussion, not a silent override — and not an automatic veto either: an RFC can be wrong for this case, so reason about it. ## Hard blockers (documented requirements — missing one blocks merge) @@ -44,7 +44,7 @@ Where your independent reasoning earns its keep. Start here, then keep going acr - **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.` (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. - **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/2026-06-19-acp-snapshot-tests.md](../../../docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md). +- **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). - **Intent and contracts.** Does the change do what the PR says, and honor the documented contract on *both* sides of every seam it touches (see AGENTS.md "Honor cross-seam contracts on BOTH sides")? ## How to respond diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index ac8565e210..2dcea37c66 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -11,7 +11,7 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba - Read `AGENTS.md`, especially the pre-release stance, tests-document-behavior section, conventions, defensive patterns, and Type Safety and Documentation section. - Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the RFC index ([docs/rfc/README.md](../../../docs/rfc/README.md)) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../../docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../../docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../../docs/rfc/implemented/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend RFCs. +- Use the RFC index ([docs/rfc/README.md](../../../docs/rfc/README.md)) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../../docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend RFCs. - Treat dual LLM adapters and dual persistence backends as intentional by default. Do not propose deleting either twin/backend as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. ## What Counts As A Strong Candidate diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2de3a6cea6..7a8c9dc88c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -23,7 +23,7 @@ name: E2E (real DeepSeek API) # in the BASE repo's context WITH secrets while still able to check out untrusted # fork code — a textbook key-leak vector, especially once this repo is public. # The fork/secret model and its public-repo implications are recorded in -# docs/rfc/implemented/2026-06-19-real-api-e2e-ci.md. +# docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md. # # Note: scheduled triggers are auto-disabled after 60 days of repo inactivity; # push/pull_request/workflow_dispatch act as backstops. diff --git a/AGENTS.md b/AGENTS.md index 8b8319ade0..1c3bc64e30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ A passing test pins the behavior the code **currently** has — not necessarily Before you preserve a behavior solely to keep a test green, ask: is this behavior load-bearing (a real consumer depends on it, a contract promises it, a user observes it), or is it an artifact? If it's an artifact, **change the behavior AND its test together, in the same change, and say why in the PR** — do not contort new code to keep an obsolete assertion passing, and do not treat "but the test expects X" as a reason X must stay. Conversely, do not delete a test just because it is inconvenient: the discipline cuts both ways — you must show the *behavior* is dead, not merely that the test is in your way. -The worked example is [Drop the mutable session summary](docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet **nothing in production CONSUMED any of it, and `update()` had no production caller**. (The backends did *write* summary state — JSONL touched the sidecar after a durable append, SQLite bumped `updated_at` in the append transaction — but those writes fed only reads that nothing performed.) The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) +The worked example is [Drop the mutable session summary](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet **nothing in production CONSUMED any of it, and `update()` had no production caller**. (The backends did *write* summary state — JSONL touched the sidecar after a durable append, SQLite bumped `updated_at` in the append transaction — but those writes fed only reads that nothing performed.) The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) ## Architecture @@ -65,8 +65,10 @@ examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent docs/ architecture.md — the design doc. module-graph.md — generated inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`). rfc/ — design decisions and proposals, one kind of doc grouped by - lifecycle into proposed/ implemented/ rejected/ (the why behind - vendoring, event-sourcing, the schema DSL, …). See rfc/README.md. + lifecycle (proposed/ implemented/ rejected/) then by class + (feature/ bug-fix/ simplification/ architecture/ process/ testing/); + the why behind vendoring, event-sourcing, the schema DSL, …. See + rfc/README.md. postmortem/ — incident write-ups: a bug that escaped to a user/merge/release, why the safety nets missed it, the guardrails added. cookbook/ — step-by-step guides: adding a package, a tool, @@ -109,7 +111,12 @@ pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-service pnpm run verify-cordis-catalog # assert that generated catalog is not stale pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) +pnpm run verify-doc-refs # assert every docs/*.md path cited in a packages|examples + # TypeScript comment resolves (catches a moved/renamed doc) +pnpm run verify-rfc-classification # assert every RFC lives in a valid + # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it + # under the matching heading (closed class set + index completeness) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-rfc-classification + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -153,7 +160,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. - **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. -- **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md). +- **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). ## Defensive patterns (hard-won) @@ -175,7 +182,7 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6d23352bff..7571209aa2 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -4,12 +4,12 @@ Conventions for authoring everything under `docs/` (architecture, RFCs, cookbook ## Cross-reference with machine-checkable links, never free prose -When one doc refers to another doc, an RFC, a package README, or any file in the repo, link it with a **relative Markdown link** to the actual path — `[capability seams](rfc/implemented/2026-06-13-capability-seams.md)`, `[architecture.md](architecture.md)`. Do NOT refer to it by bare prose or by a number ("see ADR 0009", "per RFC 005"): a number is not checkable, goes stale the moment a file is renamed, and forces the reader to go hunting. A relative link is verified mechanically — `pnpm run verify-md-links` (part of `doc-sync`, see [the cross-link lint RFC](rfc/implemented/2026-06-18-markdown-cross-link-lint.md)) fails CI and the pre-push hook if any relative target does not exist, so a rename that orphans a link is caught before review rather than rotting silently. +When one doc refers to another doc, an RFC, a package README, or any file in the repo, link it with a **relative Markdown link** to the actual path — `[capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)`, `[architecture.md](architecture.md)`. Do NOT refer to it by bare prose or by a number ("see ADR 0009", "per RFC 005"): a number is not checkable, goes stale the moment a file is renamed, and forces the reader to go hunting. A relative link is verified mechanically — `pnpm run verify-md-links` (part of `doc-sync`, see [the cross-link lint RFC](rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md)) fails CI and the pre-push hook if any relative target does not exist, so a rename that orphans a link is caught before review rather than rotting silently. -This is why the RFC tree carries no stable numbers: files are named `yyyy-mm-dd-topic-title.md` and referred to by link, so they survive moves between `proposed/`/`implemented/`/`rejected/` without a dangling reference. When you move or rename a doc, the gate tells you every inbound link you still need to fix. +This is why the RFC tree carries no stable numbers: files are named `yyyy-mm-dd-topic-title.md` and referred to by link, so they survive moves between lifecycle folders (`proposed/`/`implemented/`/`rejected/`) and class folders without a dangling reference. When you move or rename a doc, the gate tells you every inbound link you still need to fix. The gate checks file *existence*, not `#anchor` validity — a link to a real file with a wrong heading fragment still passes. Prefer linking the file (and a heading when it helps the reader), but don't rely on the gate to catch a stale anchor. ## RFCs -Design decisions and proposals live in [rfc/](rfc/) — one kind of doc, grouped by lifecycle into `proposed/`/`implemented/`/`rejected/`. See [rfc/README.md](rfc/README.md) for the naming scheme and when to write one. +Design decisions and proposals live in [rfc/](rfc/) — one kind of doc, grouped by lifecycle (`proposed/`/`implemented/`/`rejected/`) then by class (`feature`/`bug-fix`/`simplification`/`architecture`/`process`/`testing`). See [rfc/README.md](rfc/README.md) for the class definitions, the naming scheme, and when to write one. diff --git a/docs/architecture.md b/docs/architecture.md index e4b9014904..2bc782cd43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `send(content)` — queued message; starts a turn when idle, else next turn - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle -- `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/2026-06-15-turn-enclosure-invariant.md)). +- `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `abort(reason)` — aborts the in-flight step via `AbortSignal` - `cancel(reason)` — the broad cancel: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. `abort()` is the narrower step-only verb; `cancel()` is what a UI/ACP `session/cancel` maps to. - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. @@ -163,9 +163,9 @@ Error containment: a throwing `agent/turn-continuation` listener or a broken ste Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. -A failure that happens once the turn is already closed has no in-turn position for a session `error` event (appending one after `turn/end` would put it past the persistence commit boundary, where it is dropped as a crash tail — [the turn-enclosure invariant](rfc/implemented/2026-06-15-turn-enclosure-invariant.md)). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. +A failure that happens once the turn is already closed has no in-turn position for a session `error` event (appending one after `turn/end` would put it past the persistence commit boundary, where it is dropped as a crash tail — [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. -**Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/2026-06-15-turn-enclosure-invariant.md). +**Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ### Event taxonomy diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 8deb9510ae..330a2db17c 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -33,7 +33,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Rules of the execute() contract -- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. +- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means). - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index fa3c754cb8..71eadb9108 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -1,6 +1,6 @@ # Cookbook: adding a vendored package -When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-http`), it is **vendored** as pinned source under `vendor/`, not added as an npm dependency — see [the vendoring decision](../rfc/implemented/2026-06-11-vendor-cordis-as-source.md) for why. [vendor/README.md](../../vendor/README.md) covers *updating* an already-vendored package; this guide is the file-by-file checklist for adding a **new** one. (Verified against the existing vendored set; if it drifts, fix it here.) +When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-http`), it is **vendored** as pinned source under `vendor/`, not added as an npm dependency — see [the vendoring decision](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md) for why. [vendor/README.md](../../vendor/README.md) covers *updating* an already-vendored package; this guide is the file-by-file checklist for adding a **new** one. (Verified against the existing vendored set; if it drifts, fix it here.) ## 1. Copy the source in diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index fc24bbd386..043140e25e 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -1,6 +1,6 @@ # Bash Executor -The bash execution seam — the canonical [capability seam](../rfc/implemented/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. +The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. Source: [`packages/bash/src/types.ts`](../../packages/bash/src/types.ts) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f232fae1c9..797299b1cb 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. -The seam is a textbook [capability seam](../rfc/implemented/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -60,4 +60,4 @@ Both implement the same abstract `SessionPersistence` (create/append/load/list/h - **[dsh-session-persistence-jsonl](../../packages/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. -Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md). +Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 5803641c27..6856daeabc 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -110,7 +110,7 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Durability contract diff --git a/docs/rfc/README.md b/docs/rfc/README.md index c1299bb23a..6f295957ab 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -4,91 +4,160 @@ One kind of design doc lives here. An **RFC** records a decision or proposal tha ## Layout and naming -Files are grouped by lifecycle into three folders, and an RFC moves between them as its status changes: +Every RFC has two axes, both encoded in its **path** — `{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`: -- **`proposed/`** — proposals reviewed before implementation; not yet built (or only partly). -- **`implemented/`** — the decision shipped. The file records what was decided and what was rejected, and is **kept current with what actually shipped**: when the code later moves a file, renames a package, or changes a key/default, the RFC is updated in the same change to match (facts only — paths, names, structure — not the decision itself). See [implemented/AGENTS.md](implemented/AGENTS.md). -- **`rejected/`** — the proposal was considered and declined. Kept for the record so the rejection isn't re-litigated. +- **Lifecycle** (the top-level folder) is the RFC's status, and an RFC moves between folders as that status changes: + - **`proposed/`** — proposals reviewed before implementation; not yet built (or only partly). + - **`implemented/`** — the decision shipped. The file records what was decided and what was rejected, and is **kept current with what actually shipped**: when the code later moves a file, renames a package, or changes a key/default, the RFC is updated in the same change to match (facts only — paths, names, structure — not the decision itself). See [implemented/AGENTS.md](implemented/AGENTS.md). + - **`rejected/`** — the proposal was considered and declined. Kept for the record so the rejection isn't re-litigated. +- **Class** (the nested folder) is the *kind* of decision — see [Classification](#classification) below. -Each file is named `yyyy-mm-dd-topic-title.md`, where the date is when the topic was **first proposed** (per git history). Cross-references between RFCs use relative markdown links (`[topic](../implemented/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders. +The date in the filename is when the topic was **first proposed** (per git history). Cross-references between RFCs use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders. + +## 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. + +| Class | What it covers | +|---|---| +| `feature` | A new user- or model-facing capability. | +| `bug-fix` | Corrects a defect or closes a gap a postmortem surfaced. | +| `simplification` | Removes code, behavior, or surface area without adding a capability. | +| `architecture` | A structural decision about the **shipped source** — how packages relate, what the runtime vocabulary is. | +| `process` | Tooling, policy, or workflow **around** the code — gates, the package manager, vendoring — not runtime behavior. | +| `testing` | Test infrastructure and strategy. | + +The `architecture` / `process` line: **architecture** is about the source we ship; **process** is the surrounding tooling and workflow. (`refactor` is deliberately absent — it overlaps `simplification`, whose discriminator, "does observable behavior change?", already covers it.) ## When to write one -Write an RFC when a decision is **durable** (it shapes the codebase beyond a single function or package), **contested** (there was a real alternative a reasonable engineer might have chosen), and **surprising** (a future reader would otherwise ask "why on earth is it done this way?"). A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. +Write an RFC when a decision is **durable** (it shapes the codebase beyond a single function or package), **contested** (there was a real alternative a reasonable engineer might have chosen), and **surprising** (a future reader would otherwise ask "why on earth is it done this way?"). A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). Do NOT write one for a mechanical or local choice (a variable name, a one-file refactor), for anything already enforced and explained by a gate or a convention in AGENTS.md, or for a still-provisional decision tagged `TODO(...)` in the code — record those as TODOs and promote to an RFC only once they settle. An RFC is never edited into a *different decision*: supersede it with a new one and cross-link. (Editing an `implemented/` RFC to track where its already-made decision now *lives* — a moved file, a renamed package — is not a different decision and is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).) ## Proposed +### Feature + | Title | First proposed | |---|---| -| [Mutation testing as the coverage counterweight](proposed/2026-06-11-mutation-testing.md) | 2026-06-11 | -| [Deterministic tests, the replay invariant fixture, and race stress](proposed/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 | -| [Architectural conformance — dependency rules and the adapter kit](proposed/2026-06-11-architectural-conformance.md) | 2026-06-11 | -| [API extractor reports](proposed/2026-06-11-api-extractor-reports.md) | 2026-06-11 | -| [Supply chain checks and vendor drift verification](proposed/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 | -| [Agent Client Protocol (ACP) support for external editors](proposed/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | -| [Multiplex concurrent ACP sessions over one connection](proposed/2026-06-14-acp-multi-session.md) | 2026-06-14 | -| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/2026-06-15-optional-code-mode.md) | 2026-06-15 | -| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/2026-06-16-typed-event-schemas.md) | 2026-06-16 | -| [Unify the agent id and the session id](proposed/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Stop mirroring durable boundaries as agent events](proposed/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | -| [Keep one public stop primitive](proposed/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Drop unconsumed assembled LLM convenience surfaces](proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | -| [Drop the unconsumed `llm/adapter-change` event](proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | -| [Prune dead methods from the persistence and bash seams](proposed/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | -| [Fold trace-only session facts into load-bearing events](proposed/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | -| [Extract a generic long-running tool runtime](proposed/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Make the shared example base providerless](proposed/2026-06-20-providerless-example-base.md) | 2026-06-20 | -| [Use `session.jsonl` as the only snapshot session-log artifact](proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | -| [Reorganize packages into a modular hierarchy](proposed/2026-06-20-package-hierarchy.md) | 2026-06-20 | -| [Discover package inventories instead of maintaining static lists](proposed/2026-06-20-discover-package-inventory.md) | 2026-06-20 | +| [Agent Client Protocol (ACP) support for 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 | + +### Simplification + +| 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 | +| [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Keep one public stop primitive](proposed/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | +| [Drop unconsumed assembled LLM convenience surfaces](proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | +| [Drop the unconsumed `llm/adapter-change` event](proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | +| [Prune dead methods from the persistence and bash seams](proposed/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | +| [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | + +### Architecture + +| Title | First proposed | +|---|---| +| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | +| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | +| [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | + +### Process + +| 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 | +| [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 | + +### 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 | +| [Use `session.jsonl` as the only snapshot session-log artifact](proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | ## Implemented +### Feature + | Title | First proposed | |---|---| -| [Vendor Cordis as source, not npm dependencies](implemented/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 | -| [Microkernel: extension via Cordis event taxonomy, one concrete loop](implemented/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | -| [Event-sourced sessions with derived message history](implemented/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | -| [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/2026-06-11-content-block-vocabulary.md) | 2026-06-11 | -| [Custom typed tool-schema DSL instead of schemastery](implemented/2026-06-11-custom-schema-dsl.md) | 2026-06-11 | -| [Tool schemas are part of the system-prompt assembly](implemented/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | -| [Mechanical quality gates over prose guidelines](implemented/2026-06-11-quality-gates.md) | 2026-06-11 | -| [tsdown for JS bundling instead of dumble](implemented/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 | -| [Runtime arg validation at the model boundary](implemented/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | -| [Dev-mode invariants over compile-time deep-readonly](implemented/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 | -| [Property-based testing for protocol-shaped code](implemented/2026-06-11-property-based-testing.md) | 2026-06-11 | -| [Doc-sync enforcement](implemented/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | -| [Markdown cross-link validity linting](implemented/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | -| [Structured error taxonomy](implemented/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 | -| [Capability seams — interface / implementation / consumer split](implemented/2026-06-13-capability-seams.md) | 2026-06-13 | -| [Two LLM adapters as a design-verification twin](implemented/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | -| [Session persistence as an abstract service over `SessionEvent`](implemented/2026-06-14-session-persistence.md) | 2026-06-14 | -| [Every session event is enclosed in a turn](implemented/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | -| [pnpm as the package manager instead of Yarn 4](implemented/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | -| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | -| [ACP snapshot tests — record-once / replay-deterministic](implemented/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | -| [Real-API e2e in CI against the external DeepSeek API](implemented/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | -| [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | -| [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | -| [Agent lifecycle and ownership seams](implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | -| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | -| [Generated cordis events + services catalog](implemented/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | +| [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 | + +### Simplification + +| Title | First proposed | +|---|---| +| [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | + +### Architecture + +| 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 | +| [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.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 | +| [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | +| [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 | + +### Process + +| Title | First proposed | +|---|---| +| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.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 | +| [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | +| [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 | + +### Testing + +| Title | First proposed | +|---|---| +| [Property-based testing for protocol-shaped code](implemented/testing/2026-06-11-property-based-testing.md) | 2026-06-11 | +| [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 | ## Rejected +### Simplification + | Title | First proposed | |---|---| -| [Deep-readonly public surfaces](rejected/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | -| [Persist assembled assistant messages, not stream chunks](rejected/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | -| [Drop ACP session/load until resume has a product shape](rejected/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | -| [Drop ACP terminal `_meta` rendering](rejected/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | -| [Drop bash full-output spill files](rejected/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | -| [Drop durable step boundary events](rejected/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | -| [Drop unused session lineage metadata](rejected/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | -| [Fold the persistence interface into dsh-session](rejected/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | -| [Collapse tool-owned UI presentation](rejected/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | -| [Retire mid-turn steering](rejected/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | -| [Return the ACP bridge to one live session per connection](rejected/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | -| [Truncate interrupted final turns on load](rejected/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | +| [Persist assembled assistant messages, not stream chunks](rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) | 2026-06-20 | +| [Drop ACP session/load until resume has a product shape](rejected/simplification/2026-06-20-drop-acp-session-load.md) | 2026-06-20 | +| [Drop ACP terminal `_meta` rendering](rejected/simplification/2026-06-20-drop-acp-terminal-meta.md) | 2026-06-20 | +| [Drop bash full-output spill files](rejected/simplification/2026-06-20-drop-bash-output-spill-files.md) | 2026-06-20 | +| [Drop durable step boundary events](rejected/simplification/2026-06-20-drop-durable-step-boundaries.md) | 2026-06-20 | +| [Drop unused session lineage metadata](rejected/simplification/2026-06-20-drop-unused-session-lineage.md) | 2026-06-20 | +| [Fold the persistence interface into dsh-session](rejected/simplification/2026-06-20-fold-session-persistence-interface.md) | 2026-06-20 | +| [Collapse tool-owned UI presentation](rejected/simplification/2026-06-20-generic-tool-rendering.md) | 2026-06-20 | +| [Retire mid-turn steering](rejected/simplification/2026-06-20-retire-mid-turn-steering.md) | 2026-06-20 | +| [Return the ACP bridge to one live session per connection](rejected/simplification/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | +| [Truncate interrupted final turns on load](rejected/simplification/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | + +### Architecture + +| Title | First proposed | +|---|---| +| [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | diff --git a/docs/rfc/implemented/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-content-block-vocabulary.md rename to docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md diff --git a/docs/rfc/implemented/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-custom-schema-dsl.md rename to docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md diff --git a/docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md similarity index 86% rename from docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md rename to docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 518cd7168d..b9a182a4e9 100644 --- a/docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -8,7 +8,7 @@ Status: implemented (accepted 2026-06-13) The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look. -Two ways to defend the log: make immutability part of the type (`DeepReadonly` on the way out), or catch corruption at runtime in dev. The runtime-validation proposal took the runtime route; [the deep-readonly proposal](../rejected/2026-06-11-immutable-public-surfaces.md) took the type route. +Two ways to defend the log: make immutability part of the type (`DeepReadonly` on the way out), or catch corruption at runtime in dev. The runtime-validation proposal took the runtime route; [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) took the type route. ## Decision @@ -26,4 +26,4 @@ The invariants encode the *real* contract, not an idealized one: a `tool/call` m - History corruption is caught loudly in tests and demos, at zero production cost and zero type noise. The trade-off is that the guarantee is dynamic (a dev-mode tripwire) rather than static. - The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract. - `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn. -- This folds in [the deep-readonly proposal](../rejected/2026-06-11-immutable-public-surfaces.md) — there is no separate deep-readonly record; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it. +- This folds in [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) — there is no separate deep-readonly record; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it. diff --git a/docs/rfc/implemented/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-event-sourced-sessions.md rename to docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md diff --git a/docs/rfc/implemented/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-microkernel-event-taxonomy.md rename to docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md diff --git a/docs/rfc/implemented/2026-06-11-runtime-arg-validation.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md similarity index 90% rename from docs/rfc/implemented/2026-06-11-runtime-arg-validation.md rename to docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md index 9998c85d35..5c0aad9eb2 100644 --- a/docs/rfc/implemented/2026-06-11-runtime-arg-validation.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -17,6 +17,6 @@ The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same struct ## Consequences - The model gets actionable feedback on its own malformed calls instead of an opaque crash, closing the gap between `InferArgs`'s promise and runtime reality. -- The validator and `InferArgs` must stay in agreement; that drift risk is to be closed by a property test ([property-based testing](2026-06-11-property-based-testing.md), not yet landed) generating args that satisfy `InferArgs` and asserting they pass `validateArgs`. Until then the agreement rests on the example tests and the shared converter structure. +- The validator and `InferArgs` must stay in agreement; that drift risk is to be closed by a property test ([property-based testing](../testing/2026-06-11-property-based-testing.md), not yet landed) generating args that satisfy `InferArgs` and asserting they pass `validateArgs`. Until then the agreement rests on the example tests and the shared converter structure. - `ToolArgsError` is a plain `Error` with a `code` field for now; if a harness-wide error taxonomy lands it becomes a subclass without changing callers that read `.message`. - Validation cost is negligible next to a model call. diff --git a/docs/rfc/implemented/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-structured-error-taxonomy.md rename to docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md diff --git a/docs/rfc/implemented/2026-06-11-tool-schemas-in-prompt-assembly.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md similarity index 100% rename from docs/rfc/implemented/2026-06-11-tool-schemas-in-prompt-assembly.md rename to docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md diff --git a/docs/rfc/implemented/2026-06-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md similarity index 90% rename from docs/rfc/implemented/2026-06-13-capability-seams.md rename to docs/rfc/implemented/architecture/2026-06-13-capability-seams.md index 3446fce9af..e66440c3ee 100644 --- a/docs/rfc/implemented/2026-06-13-capability-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md @@ -26,4 +26,4 @@ The split is not mandatory when the parts are genuinely one concern: the LLM sea ## Consequences -More packages and more boilerplate per capability (a `package.json`/`tsconfig`/README trio, the inject wiring). Bought: implementations and consumers ship and version independently, and a new backend never risks the model-facing contract. The rule is documented in [AGENTS.md](../../../AGENTS.md) § Conventions ("Capability seams are three packages") and [architecture.md](../../architecture.md) § "Capability seams"; the bash trio is the reference template. When to fold vs. split is a judgment call the architecture doc spells out — this RFC records *why* the default is to split. +More packages and more boilerplate per capability (a `package.json`/`tsconfig`/README trio, the inject wiring). Bought: implementations and consumers ship and version independently, and a new backend never risks the model-facing contract. The rule is documented in [AGENTS.md](../../../../AGENTS.md) § Conventions ("Capability seams are three packages") and [architecture.md](../../../architecture.md) § "Capability seams"; the bash trio is the reference template. When to fold vs. split is a judgment call the architecture doc spells out — this RFC records *why* the default is to split. diff --git a/docs/rfc/implemented/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md similarity index 93% rename from docs/rfc/implemented/2026-06-13-twin-llm-adapters.md rename to docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index 6d6ded31e4..ecd21b5cc3 100644 --- a/docs/rfc/implemented/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -21,4 +21,4 @@ Alternatives considered: **a single adapter** — less code and half the e2e cos ## Consequences -Double the adapter maintenance and double the key-gated e2e surface (both adapters cover V4 Flash and Pro across representative thinking/effort modes). Bought: a continuously-verified neutrality guarantee for the most leak-prone abstraction in the codebase, and a worked second example for adapter authors. The two share the core Config shape (`apiKey`/`baseURL`/`models`) so a deployment swaps mostly one line, but the reasoning knob differs — `dsh-llm-deepseek` takes `thinking`/`reasoningEffort`, `dsh-llm-pi-ai` takes a single `reasoning` level — so a swap translates that field. If the maintenance cost ever outweighs the verification value (e.g. once conformance tests from [architectural conformance](../proposed/2026-06-11-architectural-conformance.md) cover the contract mechanically), retiring the twin to a single adapter + the conformance kit would be a new RFC superseding this one. +Double the adapter maintenance and double the key-gated e2e surface (both adapters cover V4 Flash and Pro across representative thinking/effort modes). Bought: a continuously-verified neutrality guarantee for the most leak-prone abstraction in the codebase, and a worked second example for adapter authors. The two share the core Config shape (`apiKey`/`baseURL`/`models`) so a deployment swaps mostly one line, but the reasoning knob differs — `dsh-llm-deepseek` takes `thinking`/`reasoningEffort`, `dsh-llm-pi-ai` takes a single `reasoning` level — so a swap translates that field. If the maintenance cost ever outweighs the verification value (e.g. once conformance tests from [architectural conformance](../../proposed/process/2026-06-11-architectural-conformance.md) cover the contract mechanically), retiring the twin to a single adapter + the conformance kit would be a new RFC superseding this one. diff --git a/docs/rfc/implemented/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md similarity index 89% rename from docs/rfc/implemented/2026-06-14-session-persistence.md rename to docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 3e66be3730..9bcd1f8c6f 100644 --- a/docs/rfc/implemented/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -8,7 +8,7 @@ Status: implemented (proposed 2026-06-14, accepted 2026-06-15) ## Context -Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../proposed/2026-06-14-acp-agent-client-protocol.md)) were all impossible. +Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible. The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append-only log the single source of truth and derives LLM history from it. Persistence had to stay faithful to that: persist the existing `SessionEvent` directly, with no parallel "persisted message" type that the log is converted to and from. The backend also had to be swappable — a file store now, a database store later — behind one interface. @@ -24,11 +24,11 @@ Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. -- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](2026-06-19-drop-mutable-session-summary.md).) +- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. Format versioning: the header carries a `version`; `load` rejects an unknown version (no v1 migration). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../proposed/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes [event-sourced sessions](2026-06-11-event-sourced-sessions.md)'s deferred "real persistence backend" and resolves its `TODO(review)` on the event vocabulary: persisting the log freezes its shape, and the `assistant/chunk` fidelity question is answered above (persist verbatim). +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes [event-sourced sessions](2026-06-11-event-sourced-sessions.md)'s deferred "real persistence backend" and resolves its `TODO(review)` on the event vocabulary: persisting the log freezes its shape, and the `assistant/chunk` fidelity question is answered above (persist verbatim). diff --git a/docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md similarity index 100% rename from docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md rename to docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md diff --git a/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md similarity index 95% rename from docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md rename to docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 839e5d6c9f..dc4b2428a1 100644 --- a/docs/rfc/implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -35,7 +35,7 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides. -This is the wiring-axis complement to the [core-data-structures catalog](../../core-data-structures/core.md) ([its RFC](2026-06-20-core-data-structures-catalog.md)): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. +This is the wiring-axis complement to the [core-data-structures catalog](../../../core-data-structures/core.md) ([its RFC](2026-06-20-core-data-structures-catalog.md)): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. ## Decision @@ -20,7 +20,7 @@ Pure generation is correct here because the codebase is disciplined enough that Specific choices: -- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../AGENTS.md). +- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). - **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. - **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. - **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md new file mode 100644 index 0000000000..d3fc95399b --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -0,0 +1,46 @@ +# RFC: Classify RFCs by kind via path-encoded subdirectories + +Status: implemented (proposed 2026-06-20, accepted 2026-06-20) + +## Context + +`docs/rfc/` grouped RFCs by **lifecycle** only — `proposed/` / `implemented/` / `rejected/`. Nothing recorded what *kind* of decision each RFC was. The index was one flat list per lifecycle, with no way to scan "show me every simplification" or "every testing-strategy decision." A wave of simplification RFCs landing on the same day made the gap concrete: a reader skimming `proposed/` could not tell a new capability from a removal from a tooling-policy change without opening each file. + +The repo's standing bias is [mechanical quality gates over prose guidelines](2026-06-11-quality-gates.md): a convention that isn't machine-checked rots. So a classification scheme here had to be enforceable, not an honor-system header. + +## Decision + +Add a second axis — the RFC's **class** — and encode it in the path: `{lifecycle}/{class}/yyyy-mm-dd-topic.md`. The folder *is* the label. A file's location declares its class, the closed set is "these folders and no others," and the existing [verify-md-links](2026-06-18-markdown-cross-link-lint.md) gate already protects the path rewrites the move required. + +### The closed set of six classes + +| Class | Covers | +|---|---| +| `feature` | A new user- or model-facing capability. | +| `bug-fix` | Corrects a defect or closes a gap a postmortem surfaced. | +| `simplification` | Removes code, behavior, or surface area without adding a capability. | +| `architecture` | A structural decision about the **shipped source** — how packages relate, what the runtime vocabulary is. | +| `process` | Tooling, policy, or workflow **around** the code, not runtime behavior. | +| `testing` | Test infrastructure and strategy. | + +The `architecture` / `process` line: **architecture** is about the source we ship; **process** is the surrounding tooling and workflow. This RFC is itself a `process` decision — it changes how the repo is organized and gated, not what the harness does at runtime — so it lives under `implemented/process/`. + +### Two gates + +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-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. + +## 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. +- 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/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md similarity index 63% rename from docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md rename to docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 6f73664c26..24f693e6b6 100644 --- a/docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -4,7 +4,7 @@ Status: implemented (proposed and accepted 2026-06-19) ## Context -The [session-persistence seam](2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMeta = SessionHeader & SessionSummary`, and the abstract `SessionPersistence` service carried a seventh method — `update(id, summary)` — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic `.summary.json` **sidecar** beside the log (temp-write + rename, best-effort), SQLite kept `updated_at`/`title`/`first_prompt` **columns** bumped inside the append transaction. +The [session-persistence seam](../architecture/2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMeta = SessionHeader & SessionSummary`, and the abstract `SessionPersistence` service carried a seventh method — `update(id, summary)` — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic `.summary.json` **sidecar** beside the log (temp-write + rename, best-effort), SQLite kept `updated_at`/`title`/`first_prompt` **columns** bumped inside the append transaction. The summary was designed for a future session picker (recency ordering via `updatedAt`, a `title`/`firstPrompt` preview). That picker was never built. An audit of the whole repo found the entire `SessionSummary` surface is **dead state**: @@ -20,12 +20,12 @@ Delete the mutable session summary entirely. `SessionSummary` and the `SessionMe Anything the summary was meant to provide is **derivable from the append-only log** when a consumer actually needs it (`firstPrompt` = first `user/message`; recency = the last event's `time` or the file mtime) or already lives in the immutable header (`createdAt`, `cwd`). The one thing *not* derivable — a user-*edited* title — had no implementation and is pure YAGNI; it can return as its own log event or header field if a real feature ever needs it. -This is recorded as a decision because it is **durable** (it narrows a public service contract and an on-disk format across two backends), **contested** (the summary was a deliberate forward-looking design, not an accident), and **surprising** (a future reader finding `SessionHeader` where the original RFC describes `SessionMeta` would otherwise ask why the summary vanished). It also unblocks the [shared persistence write coordinator](2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. +This is recorded as a decision because it is **durable** (it narrows a public service contract and an on-disk format across two backends), **contested** (the summary was a deliberate forward-looking design, not an accident), and **surprising** (a future reader finding `SessionHeader` where the original RFC describes `SessionMeta` would otherwise ask why the summary vanished). It also unblocks the [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md): with no mutable summary, the coordinator's hook interface needs no `updateSummary` hook and the JSONL-sidecar-vs-SQLite-column durability divergence disappears, so the two backends' write paths converge. ## No migration -This is unreleased software (see [root AGENTS.md](../../../AGENTS.md) § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the `openDatabase` guard now rejects any non-current on-disk `user_version` (`onDisk !== 0 && onDisk !== SCHEMA_VERSION`) — older *or* newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work. +This is unreleased software (see [root AGENTS.md](../../../../AGENTS.md) § "Pre-release stance: foundation over blast radius"), so there are no on-disk databases or logs to preserve. SQLite does not migrate a v1 database: the `openDatabase` guard now rejects any non-current on-disk `user_version` (`onDisk !== 0 && onDisk !== SCHEMA_VERSION`) — older *or* newer — so a stale v1 DB is cleanly rejected rather than half-read against the new column set. A fresh database stamps the current version; that is the only path that needs to work. ## What we gave up -A future session picker now has to derive its preview/ordering from the log (or reintroduce a typed field) rather than reading a ready-made summary row. That is the correct cost: a cache for a feature that does not exist is dead weight that every backend pays to maintain and every contract test pays to assert. The principle — **a passing test pins current behavior, not necessarily correct behavior; behavior can be an artifact of a past compromise** — is now recorded as a standalone convention in [root AGENTS.md](../../../AGENTS.md), with this change as its worked example. +A future session picker now has to derive its preview/ordering from the log (or reintroduce a typed field) rather than reading a ready-made summary row. That is the correct cost: a cache for a feature that does not exist is dead weight that every backend pays to maintain and every contract test pays to assert. The principle — **a passing test pins current behavior, not necessarily correct behavior; behavior can be an artifact of a past compromise** — is now recorded as a standalone convention in [root AGENTS.md](../../../../AGENTS.md), with this change as its worked example. diff --git a/docs/rfc/implemented/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md similarity index 92% rename from docs/rfc/implemented/2026-06-11-property-based-testing.md rename to docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 9e4300cf65..19e371b1b0 100644 --- a/docs/rfc/implemented/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -16,7 +16,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe - **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `flushReady()+flushRemaining() ≡ blocks()` in order; the streamed prefix is always a prefix of final `blocks()`; partial count ≤ distinct indices; re-assembly idempotent. - **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log. -- **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk. +- **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk. - **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine. ## Consequences diff --git a/docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md similarity index 75% rename from docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md rename to docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index cf61386509..a0f6497c2a 100644 --- a/docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -6,7 +6,7 @@ Status: implemented (accepted 2026-06-19) ## Context -The harness has two test tiers: keyless unit `.spec.ts` (the 100%-per-file coverage gate) and real-API `.e2e.ts` (key-gated, self-skipping in CI). Neither continuously verifies the **complete output transcript** an ACP editor (Zed) sees on its stdin/stdout. The existing ACP e2e ([examples/acp-agent/tests/acp.e2e.ts](../../../examples/acp-agent/tests/acp.e2e.ts)) is the closest end-to-end check, but it is key-gated and asserts on a handful of *structured fields* (`stopReason`, a `tool_call` title), not the byte-for-byte stream of `session/update` frames. That leaves the "green units, broken product" gap: every unit test can pass while the actual editor-facing protocol output regresses — the same class of failure that shipped the inject bug ([docs/postmortem/0001](../../postmortem/0001-acp-default-export-drops-inject.md)), where 178 hand-mounted tests stayed green while a real Zed session crashed instantly. +The harness has two test tiers: keyless unit `.spec.ts` (the 100%-per-file coverage gate) and real-API `.e2e.ts` (key-gated, self-skipping in CI). Neither continuously verifies the **complete output transcript** an ACP editor (Zed) sees on its stdin/stdout. The existing ACP e2e ([examples/acp-agent/tests/acp.e2e.ts](../../../../examples/acp-agent/tests/acp.e2e.ts)) is the closest end-to-end check, but it is key-gated and asserts on a handful of *structured fields* (`stopReason`, a `tool_call` title), not the byte-for-byte stream of `session/update` frames. That leaves the "green units, broken product" gap: every unit test can pass while the actual editor-facing protocol output regresses — the same class of failure that shipped the inject bug ([docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md)), where 178 hand-mounted tests stayed green while a real Zed session crashed instantly. The blocker for a full-transcript test is the model: the agent's output is driven by a non-deterministic LLM, and a key-gated test that hits the real API on every run is neither deterministic nor CI-runnable. We want the fidelity of a real run with the determinism of a fixture. @@ -18,13 +18,13 @@ A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it ov ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/session/src/types.ts](../../../packages/session/src/types.ts): "raw chunks are the replay record"). +The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/session/src/types.ts](../../../../packages/session/src/types.ts): "raw chunks are the replay record"). An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. ### Replay derives the model script from the log -The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/agent-loop/src/loop.ts](../../../packages/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. +The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. ### The in-memory replay entry honors the full LLM contract @@ -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. -`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm-deepseek/src/index.ts](../../../packages/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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. +`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm-deepseek/src/index.ts](../../../../packages/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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. ### Two goldens: normalize, then snapshot @@ -55,18 +55,18 @@ A snapshot run asserts **two** normalized goldens, because the harness's externa 1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). 2. The **re-derived session JSONL** — the log the replay run itself persists, compared against the recorded fixture. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. -The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../proposed/2026-06-11-deterministic-and-stress-testing.md) idea. +The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the snapshot: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The goldens are themselves **JSONL** — one compact, normalized record per line, in the same shape as the surfaces they mirror (NDJSON on the wire, JSONL on disk: `stdout.golden.jsonl`, `session.golden.jsonl`), so they stay `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the golden store and the `-u`/`--update` "accept the diff" workflow. ### Isolation: normalization now, sandbox later -Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the executor's existing secret-scrubbing env (`/KEY|SECRET|TOKEN/i`), the fresh non-login `bash -c` per call, and the normalization pass — **not** from an OS sandbox. A real rootless sandbox (bwrap on Linux, sandbox-exec/Seatbelt on macOS) is the established cross-platform pattern (Claude Code, Codex), but it is per-OS, fragile on newer kernels (Ubuntu 24.04+ AppArmor blocks unprivileged user namespaces), and unnecessary for transcript determinism. It is reserved as a future tier via the documented `BashExecutor` capability seam ([a sandboxing executor replaces dsh-bash-local without touching a tool schema](2026-06-13-capability-seams.md)) — a new `bash-*` package, not a change here. Scenarios keep bash commands tightly constrained (no `date`/`env`/background/large-output) so the temp-dir tier suffices. +Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the executor's existing secret-scrubbing env (`/KEY|SECRET|TOKEN/i`), the fresh non-login `bash -c` per call, and the normalization pass — **not** from an OS sandbox. A real rootless sandbox (bwrap on Linux, sandbox-exec/Seatbelt on macOS) is the established cross-platform pattern (Claude Code, Codex), but it is per-OS, fragile on newer kernels (Ubuntu 24.04+ AppArmor blocks unprivileged user namespaces), and unnecessary for transcript determinism. It is reserved as a future tier via the documented `BashExecutor` capability seam ([a sandboxing executor replaces dsh-bash-local without touching a tool schema](../architecture/2026-06-13-capability-seams.md)) — a new `bash-*` package, not a change here. Scenarios keep bash commands tightly constrained (no `date`/`env`/background/large-output) so the temp-dir tier suffices. ### The replay plugin is its own package -The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. +The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. ### Two subcommands, replay in the default gate @@ -76,4 +76,4 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the two `*.golden.jsonl` files, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the goldens — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples. -This RFC relates to but does not supersede the [proposed determinism RFC](../proposed/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. +This RFC relates to but does not supersede the [proposed determinism RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/docs/rfc/implemented/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md similarity index 88% rename from docs/rfc/implemented/2026-06-19-real-api-e2e-ci.md rename to docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index ed52c9f23c..b6fc29ee90 100644 --- a/docs/rfc/implemented/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -6,15 +6,15 @@ Status: implemented (accepted 2026-06-19) ## Context -The harness leans hard on real-API tests by policy: AGENTS.md § Secrets argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. +The harness leans hard on real-API tests by policy: AGENTS.md § Secrets argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. -But until this change **nothing in CI ran it**. The default gate ([.github/workflows/ci.yml](../../../.github/workflows/ci.yml)) is deliberately keyless — it carries no secret, runs on every push and PR including from forks, and stays green for any contributor. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so even if ci.yml invoked it, a keyless runner would skip it green. The real-API safety net therefore only fired when a developer happened to run it locally with a key in their environment — i.e. unreliably, and never as a merge gate. +But until this change **nothing in CI ran it**. The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless — it carries no secret, runs on every push and PR including from forks, and stays green for any contributor. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so even if ci.yml invoked it, a keyless runner would skip it green. The real-API safety net therefore only fired when a developer happened to run it locally with a key in their environment — i.e. unreliably, and never as a merge gate. This RFC records the decision to add a **second, secret-consuming workflow** that runs the real-API suite in CI, and — because introducing the first CI secret into a repo that may later go public is a security/isolation decision — the threat model it relies on and what changes when the repo becomes public. ## Decision -Add a dedicated workflow, [.github/workflows/e2e.yml](../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. ci.yml is left untouched. +Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. ci.yml is left untouched. ### A separate workflow, not a job in ci.yml @@ -51,7 +51,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm-deepseek/src/index.ts](../../../packages/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm-deepseek/src/index.ts](../../../../packages/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value, not its length. (An earlier draft echoed `${#KEY}`; dropped as needless metadata.) ### Scope, runtime shape diff --git a/docs/rfc/proposed/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/2026-06-20-providerless-example-base.md deleted file mode 100644 index a824dd4fa1..0000000000 --- a/docs/rfc/proposed/2026-06-20-providerless-example-base.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Make the shared example base providerless - -Status: proposed - -## Problem - -The examples have two shared base files: [examples/base-core.yml](../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. - -The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. - -## Proposal - -Rename the providerless core to [examples/base.yml](../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../examples/base-core.yml). - -The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. - -## Acceptance criteria - -- [examples/base.yml](../../../examples/base.yml) is providerless. -- [examples/base-core.yml](../../../examples/base-core.yml) is deleted. -- Real demo configs explicitly add the DeepSeek adapter. -- Snapshot replay config includes the same providerless base and its replay adapter. -- The [examples README](../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter". - -## What we give up - -Real demos lose one layer of convenience: each must opt into the adapter. That is the right default for examples, because adapter choice is the variable part and providerless wiring is the shared product core. diff --git a/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md deleted file mode 100644 index d38583c60b..0000000000 --- a/docs/rfc/proposed/2026-06-20-prune-dead-seam-methods.md +++ /dev/null @@ -1,49 +0,0 @@ -# RFC: Prune dead methods from the persistence and bash capability seams - -Status: proposed - -## Problem - -Two capability seams ([interface / implementation / consumer](../implemented/2026-06-13-capability-seams.md)) carry abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. - -### `SessionPersistence.has()` and `.delete()` - -The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/src/index.ts:142-151](../../../packages/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/agent-loop/src/index.ts:176-194](../../../packages/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/acp/src/index.ts](../../../packages/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. - -`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/src/coordinator.ts:298-310](../../../packages/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../packages/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../packages/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. - -### `BashExecutor.get()` and `.list()` - -The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/src/index.ts:88-107](../../../packages/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash-local/src/index.ts:179-191](../../../packages/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/tests/service.spec.ts](../../../packages/bash/tests/service.spec.ts), [packages/bash-local/tests/executor.spec.ts](../../../packages/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/tool-bash/tests/tools.spec.ts](../../../packages/tool-bash/tests/tools.spec.ts), [packages/tool-bash/tests/integration.spec.ts](../../../packages/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. - -## Proposal - -Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them: - -- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../implemented/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. -- `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. -- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/README.md](../../../packages/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/README.md](../../../packages/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence-sqlite/README.md](../../../packages/session-persistence-sqlite/README.md), [packages/session-persistence-jsonl/README.md](../../../packages/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../docs/architecture.md), and the persistence prose in the [session-persistence RFC](../implemented/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../implemented/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/src/index.ts](../../../packages/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/src/coordinator.ts](../../../packages/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. - -## Why not keep them as "the seam should be complete"? - -The instinct that a persistence seam "should" offer delete, or a task executor "should" offer enumeration, is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). Each of these is one method to re-add the day a consumer needs it: - -- A session-management UI that deletes old sessions will want `delete()` — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now. -- A `bash_list` tool that shows the model its running tasks will want `list()` — add it with the tool. - -Re-adding a seam method with a live consumer is cheap and better-designed than the speculative version, because the consumer pins the contract. Carrying it unused means every implementation (and every future backend) must implement and test a method that does nothing. - -## Acceptance criteria - -- `has`/`delete`/`deleteStored` and `get`/`list` are gone from their seams, impls, and contract suites; `pnpm run knip` reports no new dead exports. -- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`ownerOf`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. -- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed methods are deleted with them). -- Seam READMEs and `docs/architecture.md` no longer list the removed methods. - -## Risks - -- **`delete()` is the kind of operation a product eventually wants.** True — but "eventually" is the point. Deleting it now and re-adding it against a real consumer is strictly better than shipping a guessed contract. The dual backends each shed a `deleteStored` impl, which is a bounded edit in otherwise-out-of-scope packages. -- **`list()` on the bash seam is the natural seed for a future `bash_list`.** Acknowledged in the [pre-release foundation stance](../../../AGENTS.md): add the seed when the tool lands. The executor still tracks tasks internally (the `tasks` map backs `ownerOf`/`readOutput`/`kill`); exposing an enumeration is a one-line re-add. -- **Low coupling.** Both removals are confined to their seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs. - -Modest size, but it converts two seams from "what an implementation must provide for nobody" back to "exactly what a consumer uses." diff --git a/docs/rfc/proposed/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md similarity index 93% rename from docs/rfc/proposed/2026-06-16-typed-event-schemas.md rename to docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 7ee53a6cb4..a48caf11d4 100644 --- a/docs/rfc/proposed/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -6,9 +6,9 @@ Status: proposed ## Problem -The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention. +The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention. -The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. Two concrete consequences surfaced in review of [the session-persistence work](../implemented/2026-06-14-session-persistence.md) (#33): +The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. Two concrete consequences surfaced in review of [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md) (#33): 1. **Persistence treats `event.data` as opaque JSON.** The JSONL/SQLite backends `JSON.stringify`/`JSON.parse` each event verbatim; the only runtime guard is `isJsonValue` (round-trip serializability — rejects BigInt, functions, cycles, non-finite numbers, …), NOT structural validation. A corrupted-but-still-JSON event datum (wrong field types, missing fields) round-trips silently and is only caught later, if at all, by a consumer's `switch`. 2. **No runtime contract for plugin-added variants.** A plugin that declaration-merges a new `SessionEventMap` key gets compile-time typing for its own code, but nothing validates that the values it produces match the shape it declared — at the producer, at the persistence boundary, or on reload. @@ -32,7 +32,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **The event producers** — 16 `session.append(...)` call sites in the loop — unchanged in shape but now validated at the boundary. - **~7 switch-consumers** that branch on these unions: `deriveMessages` (`dsh-session`), `BlockAssembler` (`dsh-llm`), the `dsh-invariants` plugin, both LLM adapters (`dsh-llm-deepseek`, `dsh-llm-pi-ai`), and the tool schema layer (`dsh-tools`). The `assertNever`-on-closed-unions vs fall-through-on-extensible-unions convention (a documented lint rule) would need rethinking — runtime variants are not statically exhaustive. - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. -- **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. +- **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. This is a HUGE change. It is not in scope for the RFC-009 session-persistence work and must not be smuggled in through it. diff --git a/docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md similarity index 85% rename from docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md rename to docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index 825fb133fe..4f034e3020 100644 --- a/docs/rfc/proposed/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -6,7 +6,7 @@ Status: proposed The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. -The [tool cookbook](../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. +The [tool cookbook](../../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. ## Proposal @@ -28,7 +28,7 @@ The runtime should own: - A shared long-running-task service or tool layer owns those semantics and is documented as the path for any future background-capable tool. - Bash background behavior remains available through the shared layer, with tests proving cross-session isolation still holds. - ACP and snapshot fixtures render background bash through the shared task vocabulary, not through bash-only lifecycle semantics. -- The [tool cookbook](../../cookbook/adding-a-tool.md) points long-running tools at the shared runtime instead of telling each tool to invent its own task protocol. +- The [tool cookbook](../../../cookbook/adding-a-tool.md) points long-running tools at the shared runtime instead of telling each tool to invent its own task protocol. ## What we give up diff --git a/docs/rfc/proposed/2026-06-20-package-hierarchy.md b/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md similarity index 88% rename from docs/rfc/proposed/2026-06-20-package-hierarchy.md rename to docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md index 7351cefc93..5eccadaef8 100644 --- a/docs/rfc/proposed/2026-06-20-package-hierarchy.md +++ b/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -`packages/` is flat. Core product packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all sit at the same level and look equally foundational. The [package README](../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more product-shaped than they are and forces publish/lint/doc scripts to encode intent through comments or static lists. +`packages/` is flat. Core product packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all sit at the same level and look equally foundational. The [package README](../../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more product-shaped than they are and forces publish/lint/doc scripts to encode intent through comments or static lists. This is not just cosmetic. A package's location currently says little about whether it is core API, a swappable capability, an adapter integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. diff --git a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md new file mode 100644 index 0000000000..2061248596 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md @@ -0,0 +1,27 @@ +# RFC: Make the shared example base providerless + +Status: proposed + +## Problem + +The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. + +The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. + +## Proposal + +Rename the providerless core to [examples/base.yml](../../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../../examples/base-core.yml). + +The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. + +## Acceptance criteria + +- [examples/base.yml](../../../../examples/base.yml) is providerless. +- [examples/base-core.yml](../../../../examples/base-core.yml) is deleted. +- Real demo configs explicitly add the DeepSeek adapter. +- Snapshot replay config includes the same providerless base and its replay adapter. +- The [examples README](../../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter". + +## What we give up + +Real demos lose one layer of convenience: each must opt into the adapter. That is the right default for examples, because adapter choice is the variable part and providerless wiring is the shared product core. diff --git a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md similarity index 70% rename from docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md rename to docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 3dc779ffa4..7056c74977 100644 --- a/docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -11,11 +11,11 @@ The coding agent is reachable only through the readline `stdio-chat` plugin: it Editors are converging on the Agent Client Protocol (ACP), which Zed and others speak: JSON-RPC 2.0 over newline-delimited stdio, modeled on the Language Server Protocol. An editor boots the agent as a subprocess and exchanges `initialize` / `session/new` / `session/prompt`, rendering streamed `session/update` notifications and `session/request_permission` prompts. The goal is for the agent to be a drop-in ACP server — implement the protocol once and run in any ACP client, with no per-editor glue. -This RFC has a hard prerequisite on [session persistence](../implemented/2026-06-14-session-persistence.md): it assumes durable session persistence (the `SessionPersistence` service and the async `AgentLoop.resume` seam) is implemented, so resuming a session via `session/load` is in scope. None of those APIs exist yet — `AgentLoop` currently exposes only the synchronous `create` — so ACP must land after, or in the same change as, [session persistence](../implemented/2026-06-14-session-persistence.md), and pins to its `resume(agentId, resumeSessionId)` contract. Session persistence persists every `SessionEvent` verbatim (including `assistant/chunk`), so a loaded session has the stream chunks needed to replay turns to the client. +This RFC has a hard prerequisite on [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md): it assumes durable session persistence (the `SessionPersistence` service and the async `AgentLoop.resume` seam) is implemented, so resuming a session via `session/load` is in scope. None of those APIs exist yet — `AgentLoop` currently exposes only the synchronous `create` — so ACP must land after, or in the same change as, [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), and pins to its `resume(agentId, resumeSessionId)` contract. Session persistence persists every `SessionEvent` verbatim (including `assistant/chunk`), so a loaded session has the stream chunks needed to replay turns to the client. ## Proposal -A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../implemented/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall. +A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall. It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. @@ -25,7 +25,7 @@ The mapping between ACP and existing harness seams — each row names the seam a |---|---|---| | `initialize` | static handler | negotiate `protocolVersion` (echo the supported version, else error); advertise text-only `promptCapabilities` and `loadSession: true`; report agent name/version | | `session/new {cwd, mcpServers, additionalDirectories}` → `{sessionId}` | the `dsh-agent` create factory (see Dependency note + Plan) | the seam must accept `{ sessionId, meta }` so the ACP-generated `sessionId` becomes the live/persisted session id and the validated `cwd` is attached as the `SessionHeader` (today `AgentLoop.create(id)` hardcodes `${id}-session` and takes no metadata); reject a 2nd session (single-session MVP, see [ACP multi-session](2026-06-14-acp-multi-session.md)); `cwd` validated (require absolute) — any absolute cwd is honored: it becomes the session's `SessionHeader.cwd` and the default bash workdir (per-session cwd, see § Deferred → RESOLVED), so the server need not launch in the workspace; non-empty `mcpServers` and `additionalDirectories` are rejected for the MVP because silently ignoring requested servers/roots would desync the client's tool and filesystem-scope UI | -| `session/load {sessionId, cwd, mcpServers, additionalDirectories}` | the `dsh-agent` resume factory ([session persistence](../implemented/2026-06-14-session-persistence.md) + Dependency note) | load `{ meta, events }`, seed the session, re-derive history via `deriveMessages()`, replay prior turns to the client as `session/update` per the ACP load contract; `mcpServers` and `additionalDirectories` rejected as in `session/new` | +| `session/load {sessionId, cwd, mcpServers, additionalDirectories}` | the `dsh-agent` resume factory ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) + Dependency note) | load `{ meta, events }`, seed the session, re-derive history via `deriveMessages()`, replay prior turns to the client as `session/update` per the ACP load contract; `mcpServers` and `additionalDirectories` rejected as in `session/new` | | `session/prompt {prompt}` | `agent.send()` (idle) | text blocks → `TextBlock`; reject image/audio per advertised capabilities; one in-flight prompt per session | | resolve `session/prompt` → `{stopReason}` | `agent/turn-end` (extended, see Plan) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics | | `session/update: agent_message_chunk` | `agent/stream-chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text | @@ -35,37 +35,37 @@ The mapping between ACP and existing harness seams — each row names the seam a | `session/request_permission {sessionId, toolCall, options}` | prepended `tools/execute` listener | no-op unless `exec.agent` is ACP-owned; await the outcome; `selected/allow_*` → `next()`; `reject_*`/`cancelled` → veto `ToolExecutionResult{isError}` | | `session/cancel` (notification) | `agent.cancel(reason)` | the queue-aware cancel (abort running step, clear queued + steering, drop an about-to-start turn); settle the in-flight prompt as `cancelled`; resolve any pending permission as `cancelled` exactly once | -The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. +The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.abort()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. -**Dependency note (architecture rule).** [docs/architecture.md](../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. +**Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. ## Plan -1. Package scaffold `packages/acp/` per [the cookbook](../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) +1. Package scaffold `packages/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps. -3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. +3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. 4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install listeners before `send()`; gate on an observed `agent/turn-start` (confirms work was accepted) then resolve on the next `agent/turn-end`; reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. -6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../implemented/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. -7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: [property-based testing](../implemented/2026-06-11-property-based-testing.md)) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a `tool_call_update` before its `tool_call`; exactly one `session/prompt` resolution per prompt; monotonic, well-formed ordering; `stopReason` in the legal set); codec unit tests over an in-memory `Duplex` pair (drive `AgentSideConnection` without a subprocess; assert exact frames for `initialize`, `session/new`, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all `ctx.on` listeners gone, any in-flight `request_permission` settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification `send()` rejects but the turn survives; `finish{kind:'error'|'aborted'}`; a `tools/execute` throw with no `tool/result`; a second `session/new` rejected; a `session/prompt` while one is in flight; an empty prompt rejected without hanging; a `session/load` re-derives identical history and replays it); and an e2e (`*.e2e.ts`, self-skips without `DEEPSEEK_API_KEY`) that boots `examples/acp-agent`, connects a `ClientSideConnection`, sends a real prompt, owns and disposes the harness in `afterEach`, and verifies the world (files on disk), not the agent's self-report. -8. Docs: module/JSDoc plus a package README; extend [the extension cookbook](../../cookbook/extension-cookbook.md) with the client-driver pattern. Flip Status to `implemented` on landing; record a decision in this RFC only if it proves durable, contested, and surprising (candidates: the `tools/execute` permission-ownership rule, the npm-dependency choice) — not auto-required. +6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. +7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: [property-based testing](../../implemented/testing/2026-06-11-property-based-testing.md)) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a `tool_call_update` before its `tool_call`; exactly one `session/prompt` resolution per prompt; monotonic, well-formed ordering; `stopReason` in the legal set); codec unit tests over an in-memory `Duplex` pair (drive `AgentSideConnection` without a subprocess; assert exact frames for `initialize`, `session/new`, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all `ctx.on` listeners gone, any in-flight `request_permission` settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification `send()` rejects but the turn survives; `finish{kind:'error'|'aborted'}`; a `tools/execute` throw with no `tool/result`; a second `session/new` rejected; a `session/prompt` while one is in flight; an empty prompt rejected without hanging; a `session/load` re-derives identical history and replays it); and an e2e (`*.e2e.ts`, self-skips without `DEEPSEEK_API_KEY`) that boots `examples/acp-agent`, connects a `ClientSideConnection`, sends a real prompt, owns and disposes the harness in `afterEach`, and verifies the world (files on disk), not the agent's self-report. +8. Docs: module/JSDoc plus a package README; extend [the extension cookbook](../../../cookbook/extension-cookbook.md) with the client-driver pattern. Flip Status to `implemented` on landing; record a decision in this RFC only if it proves durable, contested, and surprising (candidates: the `tools/execute` permission-ownership rule, the npm-dependency choice) — not auto-required. Deferred (each names its owning future work): - Multiplexing concurrent sessions → [ACP multi-session](2026-06-14-acp-multi-session.md). - ~~`cwd` honoring.~~ **RESOLVED.** Originally there was no path from `session/new.cwd` to the bash workdir (`tool-bash` forwarded only an explicit `args.workdir`; `LocalBashExecutor.resolve` defaulted to its own config or `process.cwd()`), so the MVP validated `cwd` (require absolute) AND required the server to launch in the workspace root, erroring on a mismatch. This is now lifted: the validated `cwd` is stored as `SessionHeader.cwd`, and `dsh-tool-bash` defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against it). Any absolute `cwd` is honored — the server need not launch in the workspace, and N sessions can each target a different directory. Widening scope beyond the single cwd (`additionalDirectories`) remains deferred. -- Client `terminal/*` proxying (a live editor terminal) and `fs/*` (editor-rendered diffs) — a future `BashExecutor` over the [capability seams](../implemented/2026-06-13-capability-seams.md) bash seam, gated on `clientCapabilities.terminal`. +- Client `terminal/*` proxying (a live editor terminal) and `fs/*` (editor-rendered diffs) — a future `BashExecutor` over the [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) bash seam, gated on `clientCapabilities.terminal`. - Image/audio prompts (blocked on the DeepSeek adapter, which skips `image` blocks today), modes, auth, `available_commands`/slash-commands, `plan`, and `usage_update`. ## Risks stdout is the protocol — guaranteed by config, not by monkey-patching. The console logger writes through `console.log` to stdout, so any stdout UI/logger plugin corrupts JSON-RPC. The guarantee is config-only: the `acp-agent` example loads no stdout plugin (no console logger, no `stdio-chat`) and, if logging is wanted, uses a stderr exporter. A defensive process-wide `process.stdout.write`/`console.log` hijack inside `dsh-acp` is explicitly rejected — it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. A test asserts the example emits only framed JSON-RPC on stdout. -New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sdk` is young (0.25.x, recently renamed) and evolving. Pin the version and isolate churn to the one bridge package. This is not a vendoring-policy violation — [vendoring Cordis as source](../implemented/2026-06-11-vendor-cordis-as-source.md) vendors the framework; genuine third-party deps already live on npm (`@earendil-works/pi-ai`). +New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sdk` is young (0.25.x, recently renamed) and evolving. Pin the version and isolate churn to the one bridge package. This is not a vendoring-policy violation — [vendoring Cordis as source](../../implemented/process/2026-06-11-vendor-cordis-as-source.md) vendors the framework; genuine third-party deps already live on npm (`@earendil-works/pi-ai`). -Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang. +Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang. Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `ReactLoopAgent`-only), not orphan awaits on a closed pipe. diff --git a/docs/rfc/proposed/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md similarity index 90% rename from docs/rfc/proposed/2026-06-14-acp-multi-session.md rename to docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md index 3b73726607..faa8a48f65 100644 --- a/docs/rfc/proposed/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md @@ -3,15 +3,15 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. -> **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../rejected/2026-06-20-single-session-acp-bridge.md). +> **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../../rejected/simplification/2026-06-20-single-session-acp-bridge.md). ## Problem [ACP support](2026-06-14-acp-agent-client-protocol.md) ships with a single active session per connection: a second `session/new` is rejected. Editors expect to run several conversations over one agent subprocess — a user opens multiple threads, or a client pre-warms sessions. The single-session guard is a deliberate MVP scope cut, not an architectural limit; this RFC lifts it. -This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in [agent lifecycle and ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md). +This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). ## Proposal diff --git a/docs/rfc/proposed/2026-06-15-optional-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md similarity index 85% rename from docs/rfc/proposed/2026-06-15-optional-code-mode.md rename to docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md index 598986a521..eb41750d1d 100644 --- a/docs/rfc/proposed/2026-06-15-optional-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` feeds its schemas into `ctx.systemPrompt`, the loop puts them on `GenerateOptions.tools`, and the adapter serializes them to the provider's function-calling wire format. The model then invokes one `tool-call` block per step, the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../architecture.md)), and **every** intermediate `tool-result` re-enters the model's context on the next request. +Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` feeds its schemas into `ctx.systemPrompt`, the loop puts them on `GenerateOptions.tools`, and the adapter serializes them to the provider's function-calling wire format. The model then invokes one `tool-call` block per step, the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), and **every** intermediate `tool-result` re-enters the model's context on the next request. For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each of those round-trips drags the entire intermediate result back into context whether the model needs it or not. @@ -16,9 +16,9 @@ This RFC proposes an **optional** Code Mode for the DeepSeek Harness, covering * ## Proposal -The design follows the codebase's capability-seam pattern ([capability seams](../implemented/2026-06-13-capability-seams.md), the `bash` template) as a three-package split, plus one consumer plugin. Nothing in `dsh-session`, `dsh-agent`, `dsh-agent-loop`, `dsh-llm`, `dsh-tools`, or `dsh-system-prompt` changes. +The design follows the codebase's capability-seam pattern ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md), the `bash` template) as a three-package split, plus one consumer plugin. Nothing in `dsh-session`, `dsh-agent`, `dsh-agent-loop`, `dsh-llm`, `dsh-tools`, or `dsh-system-prompt` changes. -**Prior art.** `@cloudflare/codemode` validates this shape directly and several of its decisions are adopted below. Its `Executor` interface is deliberately tiny — `execute(code, fns) → { result, error?, logs? }` — with a production `DynamicWorkerExecutor` (isolated Workers) and a six-line `NodeVMExecutor` example as two implementations behind it: exactly the interface/implementation split [the capability-seam pattern](../implemented/2026-06-13-capability-seams.md) prescribes. It generates TypeScript type definitions from tools for the model's context and runs the generated JavaScript in a sandbox, capturing console output alongside the return value. It normalizes model output into an async arrow function via AST parsing (acorn) and sanitizes tool names into valid JS identifiers (`my-tool` → `my_tool`, `delete` → `delete_`). It blocks outbound network by default. The transferable lessons — minimal executor contract, host-side type derivation, capture-output-and-return-value, name sanitization, AST-normalize the code, isolate by default — are folded into the design below. What does **not** transfer is the substrate: Cloudflare's isolation is Workers-specific; our equivalent hardened substrate is the deferred follow-up. +**Prior art.** `@cloudflare/codemode` validates this shape directly and several of its decisions are adopted below. Its `Executor` interface is deliberately tiny — `execute(code, fns) → { result, error?, logs? }` — with a production `DynamicWorkerExecutor` (isolated Workers) and a six-line `NodeVMExecutor` example as two implementations behind it: exactly the interface/implementation split [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) prescribes. It generates TypeScript type definitions from tools for the model's context and runs the generated JavaScript in a sandbox, capturing console output alongside the return value. It normalizes model output into an async arrow function via AST parsing (acorn) and sanitizes tool names into valid JS identifiers (`my-tool` → `my_tool`, `delete` → `delete_`). It blocks outbound network by default. The transferable lessons — minimal executor contract, host-side type derivation, capture-output-and-return-value, name sanitization, AST-normalize the code, isolate by default — are folded into the design below. What does **not** transfer is the substrate: Cloudflare's isolation is Workers-specific; our equivalent hardened substrate is the deferred follow-up. **Prompt-budget tradeoff (Code Mode is not unconditionally cheaper).** Deriving the SDK types host-side costs no extra *discovery* round-trip, but the generated `.d.ts` is injected into the system prompt (§3a), so the type definitions themselves **do** consume context — and for an all-tools SDK that cost scales with every registered tool and can be comparable to, or larger than, the native JSON schemas it replaces. Code Mode's saving is on the **output/result** side (the model curates what comes back; intermediate results never re-enter context) and on **round-trips** (compose many calls in one program), not on the input-side tool description. The net win is workload-dependent: it pays off for multi-call, large-intermediate-result workflows and can cost *more* for a single call against a large tool surface. The `.d.ts` section is a prefix-stable prompt prefix, so prompt caching amortizes its per-turn cost across a session; the RFC notes that caching is what keeps the injected SDK affordable, and that a deployment with a very large tool surface should weigh the SDK size against native schemas rather than assume Code Mode is strictly cheaper. @@ -29,7 +29,7 @@ The design follows the codebase's capability-seam pattern ([capability seams](.. - a readonly `safe: boolean` on the `CodeRuntime` service — `false` for an unsandboxed stub, `true` only for a real isolating substrate; consumers gate on it (§2). - `SdkBinding = { namespace: string; fns: Record Promise> }` -Per the "explicit > implicit at seams" convention, the request spells out every field the runtime acts on; defaulting (e.g. an output cap, a timeout derived from `signal`) is the implementation's explicit job, not a hidden `?? default` inside `run()`. The split into interface + implementation is justified under [the capability-seam pattern](../implemented/2026-06-13-capability-seams.md) because there is **genuinely more than one planned implementation** — the node:vm stub *and* the hardened substrate (a real isolate, or the generated program run as a sandboxed process through the existing `ctx.bash` seam) that is scheduled follow-up work, not speculative optionality. The capability-seam pattern warns against splitting preemptively when only one implementation is conceivable; here a second is not just conceivable but required before any untrusted use, so the seam earns its keep. +Per the "explicit > implicit at seams" convention, the request spells out every field the runtime acts on; defaulting (e.g. an output cap, a timeout derived from `signal`) is the implementation's explicit job, not a hidden `?? default` inside `run()`. The split into interface + implementation is justified under [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) because there is **genuinely more than one planned implementation** — the node:vm stub *and* the hardened substrate (a real isolate, or the generated program run as a sandboxed process through the existing `ctx.bash` seam) that is scheduled follow-up work, not speculative optionality. The capability-seam pattern warns against splitting preemptively when only one implementation is conceivable; here a second is not just conceivable but required before any untrusted use, so the seam earns its keep. **Backends can differ by language/runtime, not only by trust level.** The two implementations above (unsafe stub vs. hardened substrate) differ along the *trust* axis while staying TypeScript/JS, but nothing in the `CodeRuntime` contract — a program string plus a set of named async SDK bindings in, and a `{ result, logs, error? }` out — is bound to one source language. The same seam can host backends that differ along the *language* axis, executing a program written in something other than TypeScript. Two illustrative directions: @@ -38,7 +38,7 @@ Per the "explicit > implicit at seams" convention, the request spells out every These are illustrations of the seam's reach, **not commitments** — the MVP ships only the TypeScript path. The honest caveat is that the *execution* contract is language-agnostic but the *presentation* is not: the SDK-generation pipeline below (§3a and the `jsonSchemaToTs` codegen, which emits a TypeScript `.d.ts`) is TypeScript-specific, so a non-TS backend pairs the shared `CodeRuntime` contract with its own language-appropriate SDK generator and system-prompt section (a `.pyi` stub and Python usage instructions for the Python backend, AssemblyScript-flavored types for that one). The runtime seam is reused as-is; only the codegen/prompt half is per-language. -**2. Implementation package `packages/code-runtime-vm/`** — a new package `@deepseek-ai/dsh-code-runtime-vm`, the `node:vm` reference stub. It type-erases the model's TypeScript via the compiler's `transpileModule` (or sucrase) — the types exist only to guide the model; the runtime is plain JS — then wraps the body in an async IIFE for top-level `await` (Cloudflare's `NodeVMExecutor` does literally `new AsyncFunction("codemode", "return await (${code})()")`), runs it in a `vm.Context` whose globals are a capturing `console` and the SDK namespace objects, awaits the IIFE, and captures the return value, the buffered logs, and any thrown error (as `error: string`). It applies an **output cap** (truncate captured logs) and a **timeout tied to `request.signal`**. These caps limit blast radius; **they are not a security boundary**. node:vm is **not** isolation: withholding `require`/`process` does not contain anything (code escapes via `constructor`/prototype reflection), and per [AGENTS.md](../../../AGENTS.md) the harness must never hand model output the ambient environment. +**2. Implementation package `packages/code-runtime-vm/`** — a new package `@deepseek-ai/dsh-code-runtime-vm`, the `node:vm` reference stub. It type-erases the model's TypeScript via the compiler's `transpileModule` (or sucrase) — the types exist only to guide the model; the runtime is plain JS — then wraps the body in an async IIFE for top-level `await` (Cloudflare's `NodeVMExecutor` does literally `new AsyncFunction("codemode", "return await (${code})()")`), runs it in a `vm.Context` whose globals are a capturing `console` and the SDK namespace objects, awaits the IIFE, and captures the return value, the buffered logs, and any thrown error (as `error: string`). It applies an **output cap** (truncate captured logs) and a **timeout tied to `request.signal`**. These caps limit blast radius; **they are not a security boundary**. node:vm is **not** isolation: withholding `require`/`process` does not contain anything (code escapes via `constructor`/prototype reflection), and per [AGENTS.md](../../../../AGENTS.md) the harness must never hand model output the ambient environment. **The unsafe-runtime guard is enforceable, not a README warning.** Because a README caveat is not a control — and AGENTS.md's "never hand model output ambient authority" is a hard rule, not advice — the design makes the danger refuse to run by construction. Two layers: @@ -63,11 +63,11 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi **Sub-call CallIds.** Real tool calls dispatched from inside `run_code` need ids, but `CallId` is normally provider-issued (a branded string for correlating a call with its result — only brand-wrapped via `CallId()`, with no generator and no documented session-global-uniqueness guarantee). The plugin mints deterministic sub-ids scoped to the parent: `` `${exec.callId}:code:${n}` `` with a per-run counter `n`. These are unique within one `run_code` run (assuming the parent `callId` is unique, which the provider guarantees per turn); the `code/dispatch` event additionally carries the session log's `seq` so the UI and persistence can order and disambiguate globally without relying on the id alone. `ToolExecution.agent` is optional; the normal loop always supplies it (and with it `exec.agent.session`, the log `code/dispatch` appends to). A `run_code` execution arriving without `exec.agent` still runs (sub-calls propagate `agent: undefined`, exactly as the loop's own contract allows) but **skips session-log observability** — with no session to append to, those direct runs are simply not logged. -**Observability without context cost.** Each sub-dispatch emits a session event **declared by the `dsh-code-mode` plugin itself** via `SessionEventMap` declaration merging (the map is merge-extensible precisely so plugins can add events without touching `dsh-session`). Shape: `code/dispatch` with `{ parentCallId, subCallId, name, arguments (or redacted), isError, summary }`, ordered by the session log's own `seq`. `deriveMessages()` does **not** translate it into a model message — an unknown event type falls through its `default`, per the merge-extensible-union convention — so the UI and persistence ([session persistence](../implemented/2026-06-14-session-persistence.md)) can render every sub-call while the model's context only ever receives the single `run_code` tool-result. Because the event lives in the plugin, this adds no core change. +**Observability without context cost.** Each sub-dispatch emits a session event **declared by the `dsh-code-mode` plugin itself** via `SessionEventMap` declaration merging (the map is merge-extensible precisely so plugins can add events without touching `dsh-session`). Shape: `code/dispatch` with `{ parentCallId, subCallId, name, arguments (or redacted), isError, summary }`, ordered by the session log's own `seq`. `deriveMessages()` does **not** translate it into a model message — an unknown event type falls through its `default`, per the merge-extensible-union convention — so the UI and persistence ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)) can render every sub-call while the model's context only ever receives the single `run_code` tool-result. Because the event lives in the plugin, this adds no core change. **SDK codegen.** A pure `jsonSchemaToTs(schema)` in `code-mode` maps the JSON-schema subset the `defineTool` DSL produces (object/string/number/boolean/array, `properties`, `required[]`, `enum` → string-literal union, nested objects, array `items`) to a TS type literal. It is **total**: any unsupported construct (`$ref`, `oneOf`/`anyOf`, `integer`, `null`, `additionalProperties`, or any raw MCP shape it does not recognize) degrades to `unknown` without throwing — it never crashes codegen. Typing is best-effort, not a guarantee, because MCP tools accept arbitrary JSON Schema and `ToolSchema.parameters` is typed only as `Record`. Because `ToolSchema.name` is an arbitrary string (not necessarily a valid TS identifier), the SDK is generated as a **namespace with quoted access** (e.g. `tools["some-mcp-tool"](args)`) plus safe camelCase aliases where the name is a clean identifier; alias collisions and TS reserved words fall back to quoted-only access (no duplicate alias emitted). This mirrors Cloudflare's `sanitizeToolName`. `run_code` itself is filtered out of the SDK. The MVP surfaces text content only; image and other block types in sub-results are deferred (noted as a limitation). -**Concurrency — serialized by default (the binding must enforce it).** The SDK functions are async, so a model writing `await Promise.all([tools.a(...), tools.b(...)])` would *start both* immediately, and each would call `ctx.tools.execute` right away — i.e. the binding shape makes concurrent dispatch the **default**, not an opt-in. Because the tool contract carries **no concurrency-safety metadata today** (parallel tool execution and a concurrency-safety hint are an open TODO in both `dsh-tools` and [docs/architecture.md](../../architecture.md): "phase 1 executes tool calls sequentially"), concurrent dispatch through a not-yet-hardened tool may race. So a prose "may serialize" is not sufficient. **Decision: the MVP SDK bindings enforce serialization** — each `run_code` invocation owns a per-run dispatch queue, and every `invoke()` chains onto it (`tail = tail.then(() => ctx.tools.execute(...))`), so even `Promise.all` over SDK calls executes them one at a time in submission order. This is a hard acceptance criterion, with a test that issues `Promise.all([...])` from a program and asserts the underlying `ctx.tools.execute` calls did **not** overlap (e.g. a probe tool records enter/exit and the test asserts no interleaving). The `.d.ts` may *describe* the model-visible functions as async (they are), but the implementation guarantees serial execution. Lifting serialization is deferred: only once a tool can declare itself read-only / concurrency-safe does the binding allow those specific tools to overlap. The same per-run queue is where the before/after abort checks (§3c) live, so an aborted run drains no further queued dispatches. +**Concurrency — serialized by default (the binding must enforce it).** The SDK functions are async, so a model writing `await Promise.all([tools.a(...), tools.b(...)])` would *start both* immediately, and each would call `ctx.tools.execute` right away — i.e. the binding shape makes concurrent dispatch the **default**, not an opt-in. Because the tool contract carries **no concurrency-safety metadata today** (parallel tool execution and a concurrency-safety hint are an open TODO in both `dsh-tools` and [docs/architecture.md](../../../architecture.md): "phase 1 executes tool calls sequentially"), concurrent dispatch through a not-yet-hardened tool may race. So a prose "may serialize" is not sufficient. **Decision: the MVP SDK bindings enforce serialization** — each `run_code` invocation owns a per-run dispatch queue, and every `invoke()` chains onto it (`tail = tail.then(() => ctx.tools.execute(...))`), so even `Promise.all` over SDK calls executes them one at a time in submission order. This is a hard acceptance criterion, with a test that issues `Promise.all([...])` from a program and asserts the underlying `ctx.tools.execute` calls did **not** overlap (e.g. a probe tool records enter/exit and the test asserts no interleaving). The `.d.ts` may *describe* the model-visible functions as async (they are), but the implementation guarantees serial execution. Lifting serialization is deferred: only once a tool can declare itself read-only / concurrency-safe does the binding allow those specific tools to overlap. The same per-run queue is where the before/after abort checks (§3c) live, so an aborted run drains no further queued dispatches. **Tool visibility tiers (design intentionally skipped).** A natural extension is to mark each tool with a *visibility tier*: some tools "direct-call eligible" (still offered as native wire tools alongside `run_code`), some "code-mode only" (reachable solely from within a `run_code` program, never on the wire), and the default "both." This would let a deployment keep a few high-frequency or approval-gated tools as direct calls while routing the long tail through Code Mode, or hide composition-only primitives from the native surface entirely. This RFC notes the possibility but **intentionally skips the detailed design** — the per-tool metadata, how it interacts with the `agent/request` enforcement in 3b, and the presentation split in 3a are left to a follow-up. The MVP is the simple two-state model: Code Mode on (everything via `run_code`) or off (everything native). @@ -75,7 +75,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi ## Alternatives -**Result elision / summarization over native tool-calling (the narrower route).** The Problem has two halves — context bloat (every intermediate `tool-result` re-enters context) and serial composition (one tool call per round-trip). The context-bloat half can be addressed *without* any code-execution runtime: keep provider tool-calling exactly as it is, and add a plugin on the `agent/request` waterfall (or a compaction pass akin to [the session-persistence work](../implemented/2026-06-14-session-persistence.md)) that elides or summarizes older `tool-result` blocks before they re-enter the model's context — drop them past a window, replace large payloads with a digest, or keep only the blocks the model still references. This is strictly less invasive than Code Mode: no new runtime seam, no model-written programs, no new safety surface. It is the right tool if context growth is the only pain. +**Result elision / summarization over native tool-calling (the narrower route).** The Problem has two halves — context bloat (every intermediate `tool-result` re-enters context) and serial composition (one tool call per round-trip). The context-bloat half can be addressed *without* any code-execution runtime: keep provider tool-calling exactly as it is, and add a plugin on the `agent/request` waterfall (or a compaction pass akin to [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md)) that elides or summarizes older `tool-result` blocks before they re-enter the model's context — drop them past a window, replace large payloads with a digest, or keep only the blocks the model still references. This is strictly less invasive than Code Mode: no new runtime seam, no model-written programs, no new safety surface. It is the right tool if context growth is the only pain. It is insufficient for the **composition / round-trip** half, which is the decisive reason this RFC does not stop there. Elision still pays one model round-trip per tool call: a loop over N items is N turns, a branch on an intermediate value is a turn to fetch then a turn to act, and post-processing (filter, join, reduce) either happens in the model's head over full payloads or not at all. Code Mode collapses all of that into one program — the loop, the branch, the join run in the runtime, and only the curated result returns. Elision also cannot express fan-out or data-dependent control flow; it only shrinks what comes back. So the two are complementary, not competing: elision could even layer *under* Code Mode for the residual native-tool paths. The RFC chooses Code Mode because the round-trip/composition cost is the larger structural limit, and accepts the new code-execution surface as the price — which is exactly why the execution substrate is gated behind the enforceable safety guard (§2) and the hardened backend is a hard prerequisite for untrusted use. @@ -83,12 +83,12 @@ It is insufficient for the **composition / round-trip** half, which is the decis ## Plan -1. Scaffold the interface package `packages/code-runtime/` per [the cookbook](../../cookbook/adding-a-package.md): abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) with a readonly `safe: boolean`, the `declare module 'cordis'` ctx key, the `CodeRunRequest`/`CodeRunResult`/`SdkBinding` vocabulary, method contracts documented in JSDoc (what `run` captures, abort semantics, that an error is a result field not a throw, what `safe` means). HMR-safety test (dispose the contributing fiber, assert `ctx.codeRuntime` is gone). +1. Scaffold the interface package `packages/code-runtime/` per [the cookbook](../../../cookbook/adding-a-package.md): abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) with a readonly `safe: boolean`, the `declare module 'cordis'` ctx key, the `CodeRunRequest`/`CodeRunResult`/`SdkBinding` vocabulary, method contracts documented in JSDoc (what `run` captures, abort semantics, that an error is a result field not a throw, what `safe` means). HMR-safety test (dispose the contributing fiber, assert `ctx.codeRuntime` is gone). 2. Scaffold the implementation package `packages/code-runtime-vm/`: the node:vm stub — `safe = false`, a constructor that **throws unless given `{ unsafe: true }`**, transpile/type-erase, async-IIFE wrap, capturing `console`, SDK globals, return-value/logs/error capture, output cap, signal-tied timeout. Tests for output capture, return value, error-as-field, abort, the constructor refusal without `unsafe`, and a README documenting the "not a sandbox, trusted-only" caveat prominently. 3. Scaffold the consumer plugin `packages/code-mode/`: `jsonSchemaToTs` codegen with namespace/quoted-access + alias handling (unit tests, including non-identifier MCP names and unsupported-shape → `unknown`); the registered lazy `ctx.systemPrompt.section()` carrying the SDK `.d.ts`; the `agent/request` listener (`prepend: true`) collapsing `request.tools` to `[run_code]` after `await next()`; the **unsafe-runtime gate** (refuse to register `run_code` when `ctx.codeRuntime.safe === false` unless `allowUnsafeRuntime` is set); the `run_code` tool with the dispatch bridge (per-run serialization queue, deterministic sub-call ids, before/after abort checks, `CodeRunError` on error results); and the `code/dispatch` event declared here via `SessionEventMap` merge. Declare `inject = ['tools', 'systemPrompt', 'codeRuntime']`. 4. Tests: HMR-safety (dispose removes the tool, the section, and the listener); a waterfall test that the wire tool list is exactly `[run_code]` (spy adapter, asserting via `agent/request` and optionally `llm/stream`); an integration test that a program calling two tools returns only its printed/returned output (verify the world, not the self-report); a **serialization test** that `Promise.all([...])` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (a probe tool records enter/exit; assert no interleaving); `deriveMessages()` ignores `code/dispatch`; abort mid-program stops further dispatches; `CodeRunError` surfaces as `isError: true`; and the **unsafe-runtime refusal test** (§3, the VM-guard): with the unsafe flag unset, a non-mock agent's `run_code` is refused; with it set, the program runs. 5. Wire an example: `examples/coding-agent-code-mode` (or a config flag on the existing example) loading the trio. Running it against the node:vm stub requires both opt-ins (`VmCodeRuntime({ unsafe: true })` and `code-mode`'s `allowUnsafeRuntime`); the example sets them explicitly and comments why, or uses a mock model — a real model never reaches the unsandboxed stub without those deliberate flags. Add a `pnpm run demo:*` entry. -6. Docs: update [docs/architecture.md](../../architecture.md) (a `ctx.codeRuntime` row in the service map, a Code Mode note under the tool pipeline / capability seams sections); add a [cookbook](../../cookbook/) note on writing a `CodeRuntime` backend; and **file the follow-up RFC for the hardened execution substrate** (the isolate/sandboxed-process design, the additional-language backends sketched in §1 — AssemblyScript/WASM, Python — with their per-language SDK generators, plus the tool-visibility-tier design skipped here). On landing, move this file to `implemented/` and update its row in [the RFC index](../README.md). +6. Docs: update [docs/architecture.md](../../../architecture.md) (a `ctx.codeRuntime` row in the service map, a Code Mode note under the tool pipeline / capability seams sections); add a [cookbook](../../../cookbook) note on writing a `CodeRuntime` backend; and **file the follow-up RFC for the hardened execution substrate** (the isolate/sandboxed-process design, the additional-language backends sketched in §1 — AssemblyScript/WASM, Python — with their per-language SDK generators, plus the tool-visibility-tier design skipped here). On landing, move this file to `implemented/` and update its row in [the RFC index](../../README.md). ## Risks diff --git a/docs/rfc/proposed/2026-06-11-api-extractor-reports.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md similarity index 86% rename from docs/rfc/proposed/2026-06-11-api-extractor-reports.md rename to docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md index db004eb8b9..c1099a6cce 100644 --- a/docs/rfc/proposed/2026-06-11-api-extractor-reports.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md @@ -4,7 +4,7 @@ Status: proposed -> Split out from the original "Doc-sync and API reports" RFC (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../implemented/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal. +> Split out from the original "Doc-sync and API reports" RFC (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../../implemented/process/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal. ## Problem diff --git a/docs/rfc/proposed/2026-06-11-architectural-conformance.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md similarity index 83% rename from docs/rfc/proposed/2026-06-11-architectural-conformance.md rename to docs/rfc/proposed/process/2026-06-11-architectural-conformance.md index 1268e6dc49..6eaf8b03a7 100644 --- a/docs/rfc/proposed/2026-06-11-architectural-conformance.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package ([the microkernel promise](../implemented/2026-06-11-microkernel-event-taxonomy.md)), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical ([the quality-gates principle](../implemented/2026-06-11-quality-gates.md)). +Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package ([the microkernel promise](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical ([the quality-gates principle](../../implemented/process/2026-06-11-quality-gates.md)). ## Proposal @@ -18,7 +18,7 @@ Two architectural guarantees currently live only in prose: (1) nothing depends o - `vendor/*` must not import from `packages/*`. - Layering: dsh-llm imports nothing from other dsh packages; dsh-session only dsh-llm; etc. (the dependency table in packages/README.md, enforced). -**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`): a reusable vitest suite parameterized by an adapter factory, asserting the chunk-protocol contract — index monotonicity per block, no deltas after `block-end` for an index, exactly one `finish`, usage at most once, every `tool-call-delta` carries the call id, abort honored promptly. Run it against the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a debug flag (pairs with [the dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md)). +**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`): a reusable vitest suite parameterized by an adapter factory, asserting the chunk-protocol contract — index monotonicity per block, no deltas after `block-end` for an index, exactly one `finish`, usage at most once, every `tool-call-delta` carries the call id, abort honored promptly. Run it against the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a debug flag (pairs with [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)). ## Plan diff --git a/docs/rfc/proposed/2026-06-11-supply-chain-and-vendor-drift.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md similarity index 79% rename from docs/rfc/proposed/2026-06-11-supply-chain-and-vendor-drift.md rename to docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md index c187c85355..e35e79f0a5 100644 --- a/docs/rfc/proposed/2026-06-11-supply-chain-and-vendor-drift.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -The vendor manifest ([the vendoring decision](../implemented/2026-06-11-vendor-cordis-as-source.md)) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence. +The vendor manifest ([the vendoring decision](../../implemented/process/2026-06-11-vendor-cordis-as-source.md)) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence. ## Proposal diff --git a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md similarity index 59% rename from docs/rfc/proposed/2026-06-20-discover-package-inventory.md rename to docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index bb2f3e95ee..281648eb51 100644 --- a/docs/rfc/proposed/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,13 +4,13 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../.github/workflows/ci.yml) and [development docs](../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. +Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make package/gate inventories discoverable. Publishability should come from the deliberate [package hierarchy](2026-06-20-package-hierarchy.md) plus package manifests, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make package/gate inventories discoverable. Publishability should come from the deliberate [package hierarchy](../architecture/2026-06-20-package-hierarchy.md) plus package manifests, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. diff --git a/docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/proposed/simplification/2026-06-20-collapse-trace-only-session-events.md similarity index 100% rename from docs/rfc/proposed/2026-06-20-collapse-trace-only-session-events.md rename to docs/rfc/proposed/simplification/2026-06-20-collapse-trace-only-session-events.md diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md similarity index 83% rename from docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md rename to docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index ab7f23296b..be39827bf0 100644 --- a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -4,9 +4,9 @@ Status: proposed ## Problem -`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. +`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. -This differs from `tools/change` and `system-prompt/change`. Those two events are also unconsumed today, but they are plausible registry-change signals for future live tool/prompt UIs. LLM adapter registration is more of a boot-time implementation detail: adapters are not a user-visible palette and the real model-call interception seam is `llm/stream`. Keeping an adapter-change event with no listener repeats the [drop-the-dead-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern at a smaller scale. +This differs from `tools/change` and `system-prompt/change`. Those two events are also unconsumed today, but they are plausible registry-change signals for future live tool/prompt UIs. LLM adapter registration is more of a boot-time implementation detail: adapters are not a user-visible palette and the real model-call interception seam is `llm/stream`. Keeping an adapter-change event with no listener repeats the [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern at a smaller scale. The event is not free. `registerAdapter()` yields its rollback disposer before emitting `llm/adapter-change` so a throwing listener unwinds the mutation instead of leaking an adapter entry, and the package carries tests for that listener-throw path. That defensive ordering protects a failure mode only tests can trigger. @@ -19,7 +19,7 @@ Remove only `llm/adapter-change`: - Simplify `registerAdapter()`'s effect generator: keep the mutation and rollback disposer for HMR/disposal, but drop the listener-throw rollback ordering that exists only for the removed event. - Remove the "Emits `llm/adapter-change` on registration and disposal" sentence from `LlmService.registerAdapter`'s JSDoc. - Rewrite the adapter-disposer test to assert the returned disposer removes the adapter without subscribing to `llm/adapter-change`; delete the listener-throw rollback test that exists solely for the removed event. -- Update the event taxonomy table in [docs/architecture.md](../../../docs/architecture.md) and [packages/llm/README.md](../../../packages/llm/README.md). The [doc-sync-enforcement RFC](../implemented/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. +- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/README.md](../../../../packages/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. ## Why not remove every registry change event? diff --git a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md similarity index 61% rename from docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md rename to docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 85198e7d84..1f0a1efadd 100644 --- a/docs/rfc/proposed/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -4,17 +4,17 @@ Status: proposed ## Problem -`LlmService` ([packages/llm/src/index.ts](../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: +`LlmService` ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: - `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. -- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../packages/llm/src/index.ts)). -- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../packages/llm/src/index.ts)). +- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/src/index.ts)). +- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/src/index.ts)). -The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. +The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. -This is the [drop-mutable-session-summary](../implemented/2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data. +This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data. -`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. +`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. ## Proposal @@ -24,9 +24,9 @@ Make `stream()` the only public LLM call surface: - Remove `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult` if no surviving API needs that named result shape. - Remove `BlockAssembler.flushReady()`, `BlockAssembler.flushRemaining()`, and the `flushed` cursor field. - Remove `BlockAssembler.result()` if it is only a helper for the deleted `generate()` service path and tests. -- Replace adapter-test use of `ctx.llm.generate()` with a small test helper that calls `ctx.llm.stream()`, pushes chunks into `BlockAssembler`, and returns the assembled message, usage, and finish reason needed by that test. That keeps the [twin-adapter design](../implemented/2026-06-13-twin-llm-adapters.md) intact while avoiding a public method whose only callers are tests. +- Replace adapter-test use of `ctx.llm.generate()` with a small test helper that calls `ctx.llm.stream()`, pushes chunks into `BlockAssembler`, and returns the assembled message, usage, and finish reason needed by that test. That keeps the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact while avoiding a public method whose only callers are tests. - Remove or rework the `flushReady`/`flushRemaining`-dependent tests. Keep assembler invariants that still apply to `push()` / `blocks()` / `message()`; delete behavior that only pins the removed flush API. -- Update every doc/comment reference to `streamBlocks`, `generate`, `GenerateResult`, and `llm/generate` across `docs/`, package READMEs, and source comments. The `ctx.llm` service-map row in [docs/architecture.md](../../../docs/architecture.md) becomes `stream()` only, the event taxonomy drops `llm/generate`, and the [property-based-testing RFC](../implemented/2026-06-11-property-based-testing.md) names block-assembly invariants without referring to removed convenience methods. +- Update every doc/comment reference to `streamBlocks`, `generate`, `GenerateResult`, and `llm/generate` across `docs/`, package READMEs, and source comments. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) becomes `stream()` only, the event taxonomy drops `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without referring to removed convenience methods. ## Acceptance criteria @@ -34,11 +34,11 @@ Make `stream()` the only public LLM call surface: - `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). - Adapter tests still exercise both real adapters through `stream()` and the shared assembler, not through a test-only public shortcut. - The loop behaves identically — verified by unchanged ACP snapshot goldens. -- `packages/llm/README.md`, [docs/architecture.md](../../../docs/architecture.md), and module docs no longer mention the removed convenience surfaces. +- `packages/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. ## Risks -- **It removes public methods from a core vocabulary package.** A future plugin that wants assembled blocks without deltas would need to call `stream()` and use `BlockAssembler` directly or reintroduce a focused helper with a real consumer. Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../AGENTS.md)), this is the right time to cut test-only public shape. +- **It removes public methods from a core vocabulary package.** A future plugin that wants assembled blocks without deltas would need to call `stream()` and use `BlockAssembler` directly or reintroduce a focused helper with a real consumer. Given the pre-release "foundation over speculative future" stance ([AGENTS.md](../../../../AGENTS.md)), this is the right time to cut test-only public shape. - **Adapter tests get a little more explicit.** They lose the ergonomic `generate()` wrapper, but that is useful pressure: tests exercise the same streaming path production uses. - **Waterfall users lose `llm/generate`.** No production listener exists. Any future caching/retry/logging plugin should wrap `llm/stream`, which remains the single provider call path. diff --git a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md new file mode 100644 index 0000000000..de79c3f355 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md @@ -0,0 +1,49 @@ +# RFC: Prune dead methods from the persistence and bash capability seams + +Status: proposed + +## Problem + +Two capability seams ([interface / implementation / consumer](../../implemented/architecture/2026-06-13-capability-seams.md)) carry abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. + +### `SessionPersistence.has()` and `.delete()` + +The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/agent-loop/src/index.ts:176-194](../../../../packages/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/acp/src/index.ts](../../../../packages/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. + +`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. + +### `BashExecutor.get()` and `.list()` + +The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/src/index.ts:88-107](../../../../packages/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash-local/src/index.ts:179-191](../../../../packages/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/tests/service.spec.ts](../../../../packages/bash/tests/service.spec.ts), [packages/bash-local/tests/executor.spec.ts](../../../../packages/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/tool-bash/tests/tools.spec.ts](../../../../packages/tool-bash/tests/tools.spec.ts), [packages/tool-bash/tests/integration.spec.ts](../../../../packages/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. + +## Proposal + +Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them: + +- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. +- `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. +- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/README.md](../../../../packages/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/README.md](../../../../packages/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence-sqlite/README.md](../../../../packages/session-persistence-sqlite/README.md), [packages/session-persistence-jsonl/README.md](../../../../packages/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/src/index.ts](../../../../packages/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. + +## Why not keep them as "the seam should be complete"? + +The instinct that a persistence seam "should" offer delete, or a task executor "should" offer enumeration, is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). Each of these is one method to re-add the day a consumer needs it: + +- A session-management UI that deletes old sessions will want `delete()` — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now. +- A `bash_list` tool that shows the model its running tasks will want `list()` — add it with the tool. + +Re-adding a seam method with a live consumer is cheap and better-designed than the speculative version, because the consumer pins the contract. Carrying it unused means every implementation (and every future backend) must implement and test a method that does nothing. + +## Acceptance criteria + +- `has`/`delete`/`deleteStored` and `get`/`list` are gone from their seams, impls, and contract suites; `pnpm run knip` reports no new dead exports. +- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`ownerOf`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. +- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed methods are deleted with them). +- Seam READMEs and `docs/architecture.md` no longer list the removed methods. + +## Risks + +- **`delete()` is the kind of operation a product eventually wants.** True — but "eventually" is the point. Deleting it now and re-adding it against a real consumer is strictly better than shipping a guessed contract. The dual backends each shed a `deleteStored` impl, which is a bounded edit in otherwise-out-of-scope packages. +- **`list()` on the bash seam is the natural seed for a future `bash_list`.** Acknowledged in the [pre-release foundation stance](../../../../AGENTS.md): add the seed when the tool lands. The executor still tracks tasks internally (the `tasks` map backs `ownerOf`/`readOutput`/`kill`); exposing an enumeration is a one-line re-add. +- **Low coupling.** Both removals are confined to their seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs. + +Modest size, but it converts two seams from "what an implementation must provide for nobody" back to "exactly what a consumer uses." diff --git a/docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md similarity index 100% rename from docs/rfc/proposed/2026-06-20-public-agent-stop-surface.md rename to docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md diff --git a/docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md similarity index 100% rename from docs/rfc/proposed/2026-06-20-remove-agent-boundary-mirror-events.md rename to docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md diff --git a/docs/rfc/proposed/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md similarity index 97% rename from docs/rfc/proposed/2026-06-20-unify-agent-and-session-id.md rename to docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 05895f51f1..19bde62d01 100644 --- a/docs/rfc/proposed/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -16,7 +16,7 @@ The agent factory carries TWO ids for what is, in every live consumer, one thing Everywhere a live consumer actually looks an agent up — the **ACP bridge, the only production path** — the two are already unified: `agentId === sessionId === `. -The separation is **latent generality no consumer exercises**: nothing reads a *stable* `agentId` back across runs (each process starts fresh, and persistence keys off the session id, never the agent id). The config path's "stable agentId, fresh sessionId" buys nothing concrete — it is cosmetic. And the `agentId !== sessionId` case is precisely what opens the bash owner-token alias hole: the bash completion-notice routes by `session.header.id`, but the registry enforces uniqueness only on `agentId`, so a programmatic caller registering two agents with different agent ids but the SAME session id can mis-route a notice (see [agent lifecycle and ownership seams](../implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) § Seam precondition). The current code documents this as a precondition rather than guaranteeing it. +The separation is **latent generality no consumer exercises**: nothing reads a *stable* `agentId` back across runs (each process starts fresh, and persistence keys off the session id, never the agent id). The config path's "stable agentId, fresh sessionId" buys nothing concrete — it is cosmetic. And the `agentId !== sessionId` case is precisely what opens the bash owner-token alias hole: the bash completion-notice routes by `session.header.id`, but the registry enforces uniqueness only on `agentId`, so a programmatic caller registering two agents with different agent ids but the SAME session id can mis-route a notice (see [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) § Seam precondition). The current code documents this as a precondition rather than guaranteeing it. ## Proposal diff --git a/docs/rfc/proposed/2026-06-11-deterministic-and-stress-testing.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md similarity index 100% rename from docs/rfc/proposed/2026-06-11-deterministic-and-stress-testing.md rename to docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md diff --git a/docs/rfc/proposed/2026-06-11-mutation-testing.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md similarity index 79% rename from docs/rfc/proposed/2026-06-11-mutation-testing.md rename to docs/rfc/proposed/testing/2026-06-11-mutation-testing.md index 8a1e422f7b..209b0cdbd0 100644 --- a/docs/rfc/proposed/2026-06-11-mutation-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md @@ -6,7 +6,7 @@ Status: proposed ## Problem -The per-file 100% coverage gate ([the quality-gates decision](../implemented/2026-06-11-quality-gates.md)) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs. +The per-file 100% coverage gate ([the quality-gates decision](../../implemented/process/2026-06-11-quality-gates.md)) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs. ## Proposal diff --git a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md similarity index 95% rename from docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md rename to docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 15b996cf61..55325a7ef7 100644 --- a/docs/rfc/proposed/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -24,7 +24,7 @@ Stdout goldens remain unchanged; they are the editor-facing projection and are n - The snapshot test derives the expected session log from `session.jsonl` for every model scenario. - Authored sidecar scenarios commit their expected produced log in `session.jsonl`; `replay.override.json` remains the model-behavior override. - Orphan-fixture guards understand which files are required by scenario kind. -- The [ACP snapshot tests RFC](../implemented/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set. +- The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set. ## What we give up diff --git a/docs/rfc/rejected/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md similarity index 72% rename from docs/rfc/rejected/2026-06-11-immutable-public-surfaces.md rename to docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 8433862e1f..0e791ef689 100644 --- a/docs/rfc/rejected/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -1,6 +1,6 @@ # RFC: Deep-readonly public surfaces -Status: rejected — the pervasive `DeepReadonly` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. The immutability *goal* shipped via that alternative; see [dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md). +Status: rejected — the pervasive `DeepReadonly` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. The immutability *goal* shipped via that alternative; see [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). @@ -10,18 +10,18 @@ The session log is append-only by contract, but `session.events` returns `readon ## Proposal -> **Implemented differently — see the Status line and [dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly` design below was rejected as written (compile-only, high type-noise, castable). What shipped: an always-on deep clone in `deriveMessages` (closing the request/adapter aliasing path) plus a dev-mode `Object.freeze` + invariants plugin. The proposal text is kept for the record. +> **Implemented differently — see the Status line and [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly` design below was rejected as written (compile-only, high type-noise, castable). What shipped: an always-on deep clone in `deriveMessages` (closing the request/adapter aliasing path) plus a dev-mode `Object.freeze` + invariants plugin. The proposal text is kept for the record. Make immutability part of the type where mutation is corruption: - `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly` utility type lands in dsh-llm next to the brand/never helpers. - `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step). - `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true). -- Optionally, dev-mode `Object.freeze` of event data behind [the dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md) flag, so sanctioned-mutation violations throw in tests rather than corrupting silently. +- Optionally, dev-mode `Object.freeze` of event data behind [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) flag, so sanctioned-mutation violations throw in tests rather than corrupting silently. ## Plan -Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside [the dev-mode invariants](../implemented/2026-06-11-dev-invariants-over-deep-readonly.md) plugin. +Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) plugin. ## Risks diff --git a/docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md similarity index 78% rename from docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md rename to docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md index 173b63e8aa..73dd601139 100644 --- a/docs/rfc/rejected/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md @@ -4,7 +4,7 @@ Status: rejected — high-fidelity chunk replay, partial failed streams, and sna ## Problem -The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence RFC](../implemented/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. +The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. For successful steps that assemble completed content, the loop already appends an `assistant/message`. That is the event `deriveMessages()` uses for the next model request. In other words, the normal resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. Failed or aborted streams are different: partial assistant output may exist only as chunks, and empty max-token steps may produce no `assistant/message` at all. @@ -17,7 +17,7 @@ ACP `session/load` can replay prior assistant messages as complete content block ## Acceptance criteria - `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed. -- [Session persistence docs](../../../packages/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. +- [Session persistence docs](../../../../packages/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. - `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks. - `session/load` renders completed assistant messages from `assistant/message`. - Stored logs get much smaller and remain `seq`-contiguous without chunk holes. @@ -29,4 +29,4 @@ The canonical user session no longer reconstructs the exact token stream of an o ## Related -This supersedes the chunk-persistence choice in [session persistence](../implemented/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../implemented/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events. +This supersedes the chunk-persistence choice in [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../../implemented/testing/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events. diff --git a/docs/rfc/rejected/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md similarity index 96% rename from docs/rfc/rejected/2026-06-20-drop-acp-session-load.md rename to docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md index 5714dc058f..c8d86066bf 100644 --- a/docs/rfc/rejected/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md @@ -18,7 +18,7 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f - `initialize` does not advertise load support. - The `session/load` handler, loading-id tracking, cwd preflight for loaded sessions, and load replay tests are removed. - Snapshot fixtures no longer rely on load replay presentation. -- [ACP docs](../../../packages/acp/README.md) describe fresh-session support only. +- [ACP docs](../../../../packages/acp/README.md) describe fresh-session support only. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md similarity index 75% rename from docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md rename to docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 89b0335275..4b7ca91a0c 100644 --- a/docs/rfc/rejected/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -4,7 +4,7 @@ Status: rejected — Zed is the current target client, and the terminal `_meta` ## Problem -The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering RFC](../implemented/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. +The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration. @@ -20,7 +20,7 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 - `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`. - `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup. - Bash result presentation no longer parses exit status for terminal pills. -- The implemented [rich ACP bash rendering RFC](../implemented/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded. +- The implemented [rich ACP bash rendering RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md similarity index 85% rename from docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md rename to docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md index ba533bba6d..a6a47c66a0 100644 --- a/docs/rfc/rejected/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md @@ -12,7 +12,7 @@ This solves a real problem, but in a narrow and leaky way. A spill path is a pro Keep tail truncation, drop full-output spill files. A bash result contains the bounded tail plus a clear truncation marker; no path is emitted. If users need full-output recovery, add a generic artifact/blob service with explicit ownership, cleanup, and UI rendering, then let bash attach large outputs to that service. -This proposal can land independently of [a generic long-running tool runtime](../proposed/2026-06-20-generic-long-running-tool-runtime.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path. +This proposal can land independently of [a generic long-running tool runtime](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md). If background tasks stay, `bash_output` should still report that output was dropped, but without advertising a spill path. ## Acceptance criteria @@ -20,7 +20,7 @@ This proposal can land independently of [a generic long-running tool runtime](.. - `OutputCollector` keeps bounded buffers only and deletes the temp-file machinery. - `renderResult()` reports truncation without a filesystem path. - Tests cover tail truncation and no longer assert full-output file contents. -- Security guidance in [root AGENTS.md](../../../AGENTS.md) stops treating private spill files as a model-visible interface. +- Security guidance in [root AGENTS.md](../../../../AGENTS.md) stops treating private spill files as a model-visible interface. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md similarity index 94% rename from docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md rename to docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md index 60b2af391a..833fae5bc6 100644 --- a/docs/rfc/rejected/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md @@ -20,7 +20,7 @@ The invariants plugin should enforce that step-scoped events have valid positive - The loop has no `closeStep()` finalization path. - ACP snapshots and persistence contract fixtures stop expecting step-boundary lines. - `deriveMessages()` and replay derive the same message history from step-scoped events. -- The [event taxonomy docs](../../architecture.md) describe turns as the durable boundary and steps as a field on step-scoped records. +- The [event taxonomy docs](../../../architecture.md) describe turns as the durable boundary and steps as a field on step-scoped records. - The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md similarity index 100% rename from docs/rfc/rejected/2026-06-20-drop-unused-session-lineage.md rename to docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md diff --git a/docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md similarity index 76% rename from docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md rename to docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index da19617793..6731b50211 100644 --- a/docs/rfc/rejected/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -12,7 +12,7 @@ The capability-seam split made sense when persistence was a new swappable backen Move the abstract `SessionPersistence` service, the coordinator, and persistence contract helpers into `dsh-session`. Keep JSONL and SQLite as separate backend packages that register the session-owned service. This preserves backend swappability while deleting one support package and one cross-package seam. -The implementing PR should update the [capability seams](../implemented/2026-06-13-capability-seams.md) guidance with the exception: persistence is not like bash or LLM because its vocabulary and lifecycle events are already the session package's core domain. +The implementing PR should update the [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) guidance with the exception: persistence is not like bash or LLM because its vocabulary and lifecycle events are already the session package's core domain. ## Acceptance criteria @@ -20,7 +20,7 @@ The implementing PR should update the [capability seams](../implemented/2026-06- - `dsh-session` exports the persistence service type, coordinator, and contract helpers. - JSONL and SQLite backend packages depend on `dsh-session` directly. - `agent-loop` resume uses the session-owned service key. -- [Session persistence](../implemented/2026-06-14-session-persistence.md), [shared persistence write coordinator](../implemented/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../packages/session-persistence/README.md) explain why backend implementations remain separate. +- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session-persistence/README.md) explain why backend implementations remain separate. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md similarity index 100% rename from docs/rfc/rejected/2026-06-20-generic-tool-rendering.md rename to docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md diff --git a/docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md similarity index 100% rename from docs/rfc/rejected/2026-06-20-retire-mid-turn-steering.md rename to docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md diff --git a/docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md similarity index 86% rename from docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md rename to docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md index ab26b9c3c9..b2a2c3b84f 100644 --- a/docs/rfc/rejected/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md @@ -4,7 +4,7 @@ Status: rejected — Zed is the current target ACP client and its ACP implementa ## Problem -The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](../proposed/2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. +The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](../../proposed/feature/2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. The product target has proven it needs concurrent editor conversations over one harness process: Zed's ACP connection owns multiple sessions and load states. The snapshot replay tier still avoids concurrent model streams because its replay entries are positional; that is a test-fixture limitation, not a reason to remove bridge multiplexing. @@ -20,7 +20,7 @@ Remove the multi-session maps and demux where a single `SessionRecord | undefine - `session/new` and `session/load` reject while that record exists. - Event handlers no longer demux across a `Map`. - Multi-session tests are removed or moved under the proposal that continues to defend multiplexing. -- The existing [multi-session ACP proposal](../proposed/2026-06-14-acp-multi-session.md) is updated to link this RFC and remains the live direction. +- The existing [multi-session ACP proposal](../../proposed/feature/2026-06-14-acp-multi-session.md) is updated to link this RFC and remains the live direction. ## What we give up diff --git a/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md similarity index 84% rename from docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md rename to docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index 771388ede9..ed8e41d598 100644 --- a/docs/rfc/rejected/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -19,7 +19,7 @@ This makes the persisted turn boundary simple: a completed `turn/end` is the che - `TurnEndReasonMap` drops the `interrupted` variant. - `interruptedTurnClosers()` and its tests disappear. - The persistence coordinator's repair hook truncates backend-specific torn/open tail state without appending closers. -- [Session persistence docs](../../../packages/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. +- [Session persistence docs](../../../../packages/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. - Snapshot and contract tests update together with the behavior they pin. - The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy, with no migration path. @@ -29,4 +29,4 @@ A crash can lose real work from the final turn: assistant text, tool calls, and ## Related -This is a direct simplification of [session persistence](../implemented/2026-06-14-session-persistence.md) and [turn enclosure](../implemented/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. +This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [turn enclosure](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index d66ee45d7f..54d64c3174 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -10,7 +10,7 @@ * shutdown flush. Two pure normalizers turn the captured stdout frames and the * session-log events into stable, snapshot-able text. * - * See docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md. + * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index f863913591..db0d493535 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -11,7 +11,7 @@ * `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/2026-06-19-acp-snapshot-tests.md. + * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. */ const SESSION_ID = '{{sessionId}}' diff --git a/package.json b/package.json index eeb5dd7d07..fadc903cbb 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,15 @@ "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/packages/README.md b/packages/README.md index b9fe1aa5f1..68d7600c85 100644 --- a/packages/README.md +++ b/packages/README.md @@ -30,7 +30,7 @@ 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) ``` -The rule: 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. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/2026-06-13-capability-seams.md)). +The rule: 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. 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)). ## What goes where diff --git a/packages/acp/README.md b/packages/acp/README.md index 2c6d531a60..b0334a0a2a 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. -It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin @@ -53,7 +53,7 @@ A tool whose call IS a shell command (`bash`) can render as a real **terminal ca - `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card. - `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). +When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). ## Settle-exactly-once @@ -65,12 +65,12 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as ## Known limitations (tracked TODOs) -- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. +- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol -The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. +The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. ## Running diff --git a/packages/acp/acp-feature-support.md b/packages/acp/acp-feature-support.md index 04b1a2f258..d5dd379bf5 100644 --- a/packages/acp/acp-feature-support.md +++ b/packages/acp/acp-feature-support.md @@ -92,7 +92,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs ## 5. Tool-call rendering -Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). +Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| @@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them | Feature | Stable | Bridge | Notes | |---|---|---|---| | `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. | -| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md). | +| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). | | Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. | | `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. | | Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. | diff --git a/packages/agent-loop/README.md b/packages/agent-loop/README.md index 5d79d90147..c51c9132b6 100644 --- a/packages/agent-loop/README.md +++ b/packages/agent-loop/README.md @@ -13,7 +13,7 @@ This is the only package in the harness that contains concrete loop logic. Every `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): - `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown. diff --git a/packages/agent/README.md b/packages/agent/README.md index 846ab17444..39fba37fd0 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -18,7 +18,7 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle. @@ -55,7 +55,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. diff --git a/packages/invariants/README.md b/packages/invariants/README.md index 094b7e13a0..a3901a0f5d 100644 --- a/packages/invariants/README.md +++ b/packages/invariants/README.md @@ -44,7 +44,7 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`). ## Why runtime, not deep-readonly types -A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md). +A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Seeded sessions diff --git a/packages/llm-replay/src/index.ts b/packages/llm-replay/src/index.ts index 9a25cc6e0c..a1c5f4f1d1 100644 --- a/packages/llm-replay/src/index.ts +++ b/packages/llm-replay/src/index.ts @@ -5,7 +5,7 @@ * waterfall (never calls `next()`) and yields model streams reconstructed from * a recorded **session JSONL** fixture — so a snapshot test can boot the real * agent against a fixed model transcript with no API key. See - * docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md. + * docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. * * The fixture IS the persisted session log (`/session.jsonl`): its * `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by diff --git a/packages/llm/README.md b/packages/llm/README.md index 3c16c3153c..02758d404c 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -43,4 +43,4 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Real adapters -Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). +Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence-jsonl/README.md index 640f132e78..92899de4b5 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence-jsonl/README.md @@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md). +- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). diff --git a/packages/session-persistence-sqlite/README.md b/packages/session-persistence-sqlite/README.md index d821b56446..74e79ac8f3 100644 --- a/packages/session-persistence-sqlite/README.md +++ b/packages/session-persistence-sqlite/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence-sqlite -A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. > **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 3ec9f7860b..42fb137287 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence -The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. +The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. @@ -39,7 +39,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md). +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/src/coordinator.ts index 5371873097..ad21f3a8ca 100644 --- a/packages/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/src/coordinator.ts @@ -18,7 +18,7 @@ * a coordinator it composes), so a third-party backend MAY implement the service * directly without using the coordinator at all. * - * See the write-coordinator RFC (docs/rfc/implemented/2026-06-18-shared-persistence-write-coordinator.md) + * See the write-coordinator RFC (docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) * for the design rationale (composition over inheritance, the opaque torn marker). * * @module @deepseek-ai/dsh-session-persistence/coordinator diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts new file mode 100644 index 0000000000..322368b724 --- /dev/null +++ b/scripts/verify-doc-refs.ts @@ -0,0 +1,96 @@ +/** + * Doc-sync gate: verify that doc references written in TypeScript COMMENTS + * resolve to a file that exists. Source comments cite docs by root-relative + * prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`, + * `docs/architecture.md § plugin checklist`. `verify-md-links` parses Markdown + * link AST and never sees these, so a doc rename or move could silently orphan + * a `.ts` comment that points at it. The RFC classification reorg + * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) + * is the motivating case: it moved every RFC under a `{class}/` folder, and + * several `.ts` doc comments cite RFC paths that changed. + * + * Detection is a token scan, NOT an AST walk: doc refs live in free prose inside + * comments, not in a structured form. We match `docs/.md` tokens and + * REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`, + * `docs/architecture.md § plugin checklist` — the section suffix is outside the + * token) is left alone rather than misread as a path. Each token is resolved + * ROOT-RELATIVE (the way the comments are written) and must exist on disk. This + * is checker, not fixer: it reports and never rewrites. + * + * Scope is repo-authored TypeScript under `packages/**` and `examples/**`, + * excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream + * source we do not own). The scan is purely textual, so it does not distinguish + * a token in a comment from one in a string literal — a `docs/….md` string in + * code is checked too, which is harmless (such a path should resolve anyway). + * + * Run: `tsx scripts/verify-doc-refs.ts`. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { glob } from 'node:fs/promises' + +const root = resolve(import.meta.dirname, '..') + +/** Repo-authored TypeScript that may cite docs in comments. */ +const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts'] + +/** Paths excluded from the scan: built output and vendored upstream source. */ +const isExcluded = (p: string): boolean => + p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/') + +/** + * Match a `docs/…​.md` reference token. The `.md` extension is required so a + * bare `docs/postmortem/0001` (no extension) does not register as a path. The + * character class stops at whitespace, backticks, parens, and the section sign, + * so trailing prose (`… .md § plugin checklist`) is not swallowed into the path. + */ +const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g + +/** A broken doc reference: a root-relative `docs/….md` token with no file. */ +interface Violation { + file: string + /** 1-based line where the reference appears. */ + line: number + ref: string +} + +/** Find every broken `docs/….md` reference in one TypeScript file. */ +function findViolations(absPath: string): Violation[] { + const file = relative(root, absPath) + const source = readFileSync(absPath, 'utf8') + const out: Violation[] = [] + const lines = source.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line === undefined) continue + for (const m of line.matchAll(DOC_REF)) { + const ref = m[0] + if (!existsSync(resolve(root, ref))) { + out.push({ file, line: i + 1, ref }) + } + } + } + return out +} + +const all: Violation[] = [] +let checked = 0 +for (const pattern of PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) { + if (isExcluded(match)) continue + checked++ + all.push(...findViolations(resolve(root, match))) + } +} + +if (all.length === 0) { + console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`) + process.exit(0) +} + +console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):') +for (const v of all) { + console.error(` ${v.file}:${v.line} ${v.ref}`) +} +process.exit(1) diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts new file mode 100644 index 0000000000..011ce9591a --- /dev/null +++ b/scripts/verify-rfc-classification.ts @@ -0,0 +1,160 @@ +/** + * Doc-sync gate: enforce the RFC classification scheme + * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.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: + * + * 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). + * + * 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. + * + * Run: `tsx scripts/verify-rfc-classification.ts`. + */ + +import { readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { glob } from 'node:fs/promises' + +const root = resolve(import.meta.dirname, '..') +const rfcRoot = resolve(root, 'docs/rfc') + +/** 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 + 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>() +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() + set.add(rel) + listed.set(key, set) + } +} + +// Every on-disk RFC must be listed under the heading matching its path. +const seenOnDisk = new Set() +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`) + } + } +} + +// --- Report ----------------------------------------------------------------- +if (errors.length === 0) { + console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`) + process.exit(0) +} + +console.error('verify-rfc-classification: violations found:') +for (const e of errors) console.error(` ${e}`) +process.exit(1) From 8c16524b901d6f0d4633e92ed8894360882c5c52 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:33:27 +0800 Subject: [PATCH 47/87] chore(knip): drop dead entry pattern and fail on config hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/acp-agent has no src/ directory — its plugins come from real packages wired via cordis.yml, and its only local entry (start.ts) is already auto-discovered. The examples/acp-agent/src/*.ts entry pattern matched nothing, which knip surfaced as a config hint that exited 0 and so went unnoticed. Remove the dead pattern, and pass --treat-config-hints-as-errors so a future stale entry (a renamed/removed path) fails the hygiene gate instead of degrading to a silent hint. --- knip.json | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/knip.json b/knip.json index f0ca39d57e..44dd3d37bb 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,6 @@ "examples/echo-agent/src/*.ts", "examples/echo-agent/tests/**/*.e2e.ts", "examples/coding-agent/tests/**/*.e2e.ts", - "examples/acp-agent/src/*.ts", "examples/acp-agent/tests/**/*.e2e.ts", "examples/acp-agent/tests/**/*.snapshot.ts" ], diff --git a/package.json b/package.json index fadc903cbb..13e289df06 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", - "knip": "knip", + "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", From ae5a908a2195818c922d56c92a1a11c251a361f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:46:59 +0800 Subject: [PATCH 48/87] docs(rfc): propose branded IDs everywhere they belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing Branded machinery (CallId/SessionId/AgentId) to the unbranded cross-boundary IDs that meet the brand.ts policy bar — chiefly the model-facing bash task id (BashTask.id, the `bash-N` counter that shares SessionId's `name-N` shape) and a distinct OwnerToken brand for the bash owner token — and fix the brand erosion where existing brands decay back to `string` at Map keys and method params. Scoped focused per the "not every string needs a brand" policy: ModelId, ToolName, numeric ordinals, and validated construction are listed as deferred extensions, not in-scope work. Filed under proposed/architecture. --- docs/rfc/README.md | 1 + .../architecture/2026-06-20-branded-ids.md | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-20-branded-ids.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6f295957ab..ca71af7578 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -65,6 +65,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md new file mode 100644 index 0000000000..ad992f6a91 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -0,0 +1,68 @@ +# RFC: Branded IDs everywhere they belong + +Status: proposed + +## Problem + +The harness already brands three identifiers — `CallId` ([`packages/llm/src/brand.ts`](../../../../packages/llm/src/brand.ts)), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. + +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. + +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". + +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare`/`get(id?: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the entire ACP session-id surface (`SessionRecord.sessionId: string`, `sessions = new Map()`, `requireSession(sessionId: string)`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. + +## Proposal + +A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. + +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts) (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). + +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts); type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) + +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. + +Illustrative shape (the factory pattern is identical to the three existing brands): + +```ts ignore-check +import type { Branded } from '@deepseek-ai/dsh-llm' + +/** A background bash task handle (generated `bash-N` by the local executor). */ +export type BashTaskId = Branded<'BashTaskId'> +export function BashTaskId(id: string): BashTaskId { + return id as BashTaskId +} + +/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */ +export type OwnerToken = Branded<'OwnerToken'> +export function OwnerToken(id: string): OwnerToken { + return id as OwnerToken +} +``` + +## Why a distinct OwnerToken brand (not SessionId) + +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. + +## Out of scope / possible extensions + +Kept deliberately narrow per the "not every string needs a brand" policy. Each of these is a plausible future brand, deferred with a reason, not a commitment: + +- **`ModelId`** (`GenerateOptions.model`, the `LlmService` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this RFC's blast radius focused. +- **`ToolName`** (the `ToolRegistry` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. +- **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. +- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. +- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own RFC, not bundled into this type-only pass. + +## Acceptance criteria + +- `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end: the executor seam, the `dsh-bash-local` generation site, and the `dsh-tool-bash` model-facing surface all speak the brands; `dsh-bash` gains no dependency on `dsh-session`. +- No `Map` keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) remains; the corresponding public method params take the brand, not `string`. +- Brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`); no `as` casts scattered at call sites. +- `pnpm run typecheck` and `pnpm run doc-sync` are green; the change is observably type-only (no snapshot, no e2e behavioral diff). + +## Risks / what we give up + +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above). +- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. +- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. From d02e9f1bd657393cb010d5d79faf753fe8f11abf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:55:20 +0800 Subject: [PATCH 49/87] Reorganize packages into a modular hierarchy Move the 18 flat packages/ packages into role-grouped dirs: core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are pure containers; each package keeps its @deepseek-ai/dsh-* name. Collapse the per-package tsconfig paths maps (base + typecheck) into one @deepseek-ai/dsh-* wildcard with a candidate per group, and derive the publint list from the hierarchy. Update all depth-coupled globs/configs (workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs, per-package tsconfigs, generators, doc-script scopes, type-equiv manifest) and the cross-package/script relative imports in tests. Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the TypeScript API instead of a regex comment-strip, which corrupted the new wildcard `/*/` path candidates. WIP: doc cross-links and package/RFC docs still to update. --- docs/cordis-catalog/events-and-services.md | 64 +-- eslint.config.mjs | 4 +- knip.json | 6 +- package.json | 2 +- packages/acp/tsconfig.json | 18 - packages/agent-loop/tsconfig.json | 19 - packages/agent/tsconfig.json | 14 - packages/bash-local/tsconfig.json | 14 - packages/{ => bash}/bash-local/README.md | 0 packages/{ => bash}/bash-local/package.json | 0 packages/{ => bash}/bash-local/src/index.ts | 0 packages/{ => bash}/bash-local/src/run.ts | 0 .../bash-local/tests/executor.spec.ts | 0 .../{ => bash}/bash-local/tests/run.spec.ts | 0 packages/bash/bash-local/tsconfig.json | 24 ++ packages/bash/{ => bash}/README.md | 0 packages/bash/{ => bash}/package.json | 0 packages/bash/{ => bash}/src/index.ts | 0 packages/bash/{ => bash}/src/types.ts | 0 .../bash/{ => bash}/tests/service.spec.ts | 0 packages/bash/bash/tsconfig.json | 18 + packages/{ => bash}/tool-bash/README.md | 0 packages/{ => bash}/tool-bash/package.json | 0 packages/{ => bash}/tool-bash/src/index.ts | 0 .../tool-bash/tests/integration.spec.ts | 2 +- .../{ => bash}/tool-bash/tests/tools.spec.ts | 0 packages/bash/tool-bash/tsconfig.json | 30 ++ packages/bash/tsconfig.json | 12 - packages/{ => core}/agent-loop/README.md | 0 packages/{ => core}/agent-loop/package.json | 0 packages/{ => core}/agent-loop/src/agent.ts | 0 packages/{ => core}/agent-loop/src/inbox.ts | 0 packages/{ => core}/agent-loop/src/index.ts | 0 packages/{ => core}/agent-loop/src/loop.ts | 0 .../{ => core}/agent-loop/tests/agent.spec.ts | 0 .../agent-loop/tests/cancel.spec.ts | 0 .../tests/config-session-id.spec.ts | 0 .../agent-loop/tests/coverage-edges.spec.ts | 0 .../{ => core}/agent-loop/tests/inbox.spec.ts | 0 .../{ => core}/agent-loop/tests/loop.spec.ts | 0 .../agent-loop/tests/mock-adapter.ts | 0 .../agent-loop/tests/properties.spec.ts | 0 .../agent-loop/tests/resume.spec.ts | 0 .../agent-loop/tests/review-fixes.spec.ts | 0 packages/core/agent-loop/tsconfig.json | 39 ++ packages/{ => core}/agent/README.md | 0 packages/{ => core}/agent/package.json | 0 packages/{ => core}/agent/src/index.ts | 0 packages/{ => core}/agent/src/types.ts | 0 packages/{ => core}/agent/tests/agent.spec.ts | 0 .../agent/tests/gen-cordis-catalog.spec.ts | 4 +- packages/core/agent/tsconfig.json | 24 ++ packages/{ => core}/session/README.md | 0 packages/{ => core}/session/package.json | 0 packages/{ => core}/session/src/index.ts | 0 packages/{ => core}/session/src/json.ts | 0 packages/{ => core}/session/src/repair.ts | 0 packages/{ => core}/session/src/types.ts | 0 .../session/tests/properties.spec.ts | 0 .../{ => core}/session/tests/repair.spec.ts | 0 .../{ => core}/session/tests/session.spec.ts | 0 packages/core/session/tsconfig.json | 21 + packages/{ => core}/system-prompt/README.md | 0 .../{ => core}/system-prompt/package.json | 0 .../{ => core}/system-prompt/src/index.ts | 0 .../system-prompt/tests/system-prompt.spec.ts | 0 packages/core/system-prompt/tsconfig.json | 21 + packages/{ => core}/tools/README.md | 0 packages/{ => core}/tools/package.json | 0 packages/{ => core}/tools/src/index.ts | 0 packages/{ => core}/tools/src/schema.ts | 0 .../{ => core}/tools/tests/properties.spec.ts | 0 packages/{ => core}/tools/tests/tools.spec.ts | 0 packages/core/tools/tsconfig.json | 27 ++ packages/invariants/tsconfig.json | 15 - packages/llm-deepseek/tsconfig.json | 14 - packages/llm-pi-ai/tsconfig.json | 14 - packages/llm-replay/tsconfig.json | 14 - packages/{ => llm}/llm-deepseek/README.md | 0 packages/{ => llm}/llm-deepseek/package.json | 0 .../{ => llm}/llm-deepseek/src/adapter.ts | 0 packages/{ => llm}/llm-deepseek/src/index.ts | 0 .../{ => llm}/llm-deepseek/src/serialize.ts | 0 packages/{ => llm}/llm-deepseek/src/sse.ts | 0 .../{ => llm}/llm-deepseek/src/translate.ts | 0 packages/{ => llm}/llm-deepseek/src/types.ts | 0 .../llm-deepseek/tests/adapter.e2e.ts | 0 .../llm-deepseek/tests/adapter.spec.ts | 0 .../llm-deepseek/tests/serialize.spec.ts | 0 .../{ => llm}/llm-deepseek/tests/sse.spec.ts | 0 .../llm-deepseek/tests/translate.spec.ts | 0 packages/llm/llm-deepseek/tsconfig.json | 24 ++ packages/{ => llm}/llm-pi-ai/README.md | 0 packages/{ => llm}/llm-pi-ai/package.json | 0 packages/{ => llm}/llm-pi-ai/src/adapter.ts | 0 packages/{ => llm}/llm-pi-ai/src/convert.ts | 0 packages/{ => llm}/llm-pi-ai/src/index.ts | 0 .../{ => llm}/llm-pi-ai/tests/adapter.e2e.ts | 0 .../{ => llm}/llm-pi-ai/tests/adapter.spec.ts | 0 .../{ => llm}/llm-pi-ai/tests/convert.spec.ts | 0 packages/llm/llm-pi-ai/tsconfig.json | 24 ++ packages/llm/{ => llm}/README.md | 0 packages/llm/{ => llm}/package.json | 0 packages/llm/{ => llm}/src/assembler.ts | 0 packages/llm/{ => llm}/src/brand.ts | 0 packages/llm/{ => llm}/src/error.ts | 0 packages/llm/{ => llm}/src/index.ts | 0 packages/llm/{ => llm}/src/never.ts | 0 packages/llm/{ => llm}/src/types.ts | 0 .../llm/{ => llm}/tests/assembler.spec.ts | 0 .../llm/{ => llm}/tests/properties.spec.ts | 0 packages/llm/{ => llm}/tests/service.spec.ts | 0 packages/llm/llm/tsconfig.json | 18 + packages/llm/tsconfig.json | 12 - .../session-persistence-jsonl/tsconfig.json | 15 - .../session-persistence-sqlite/tsconfig.json | 15 - .../session-persistence-jsonl/README.md | 0 .../session-persistence-jsonl/package.json | 0 .../session-persistence-jsonl/src/format.ts | 0 .../session-persistence-jsonl/src/index.ts | 0 .../tests/jsonl.spec.ts | 0 .../session-persistence-jsonl/tsconfig.json | 27 ++ .../session-persistence-sqlite/README.md | 0 .../session-persistence-sqlite/package.json | 0 .../session-persistence-sqlite/src/index.ts | 0 .../session-persistence-sqlite/src/schema.ts | 0 .../tests/sqlite.spec.ts | 0 .../session-persistence-sqlite/tsconfig.json | 27 ++ .../{ => session-persistence}/README.md | 0 .../{ => session-persistence}/package.json | 0 .../src/coordinator.ts | 0 .../{ => session-persistence}/src/index.ts | 0 .../tests/contract.ts | 0 .../tests/coordinator-contract.ts | 0 .../tests/persistence.spec.ts | 0 .../session-persistence/tsconfig.json | 21 + packages/session-persistence/tsconfig.json | 13 - packages/session/tsconfig.json | 13 - packages/{ => support}/invariants/README.md | 0 .../{ => support}/invariants/package.json | 0 .../{ => support}/invariants/src/index.ts | 0 .../invariants/tests/invariants.spec.ts | 0 packages/support/invariants/tsconfig.json | 27 ++ packages/{ => support}/llm-replay/README.md | 0 .../{ => support}/llm-replay/package.json | 0 .../{ => support}/llm-replay/src/index.ts | 0 .../llm-replay/tests/llm-replay.spec.ts | 0 packages/support/llm-replay/tsconfig.json | 24 ++ packages/{ => support}/ui-stdio/README.md | 0 packages/{ => support}/ui-stdio/package.json | 0 packages/{ => support}/ui-stdio/src/index.ts | 0 .../ui-stdio/tests/ui-stdio.spec.ts | 0 packages/support/ui-stdio/tsconfig.json | 30 ++ packages/system-prompt/tsconfig.json | 13 - packages/tool-bash/tsconfig.json | 16 - packages/tools/tsconfig.json | 15 - packages/ui-stdio/tsconfig.json | 16 - packages/{ => ui}/acp/README.md | 0 packages/{ => ui}/acp/acp-feature-support.md | 0 packages/{ => ui}/acp/package.json | 0 packages/{ => ui}/acp/src/codec.ts | 0 packages/{ => ui}/acp/src/index.ts | 0 packages/{ => ui}/acp/tests/bridge.spec.ts | 0 packages/{ => ui}/acp/tests/codec.spec.ts | 0 packages/{ => ui}/acp/tests/dispose.spec.ts | 0 packages/{ => ui}/acp/tests/edges.spec.ts | 0 packages/{ => ui}/acp/tests/harness.ts | 0 packages/{ => ui}/acp/tests/load.spec.ts | 0 .../{ => ui}/acp/tests/multi-session.spec.ts | 0 .../{ => ui}/acp/tests/properties.spec.ts | 0 .../{ => ui}/acp/tests/stream-update.spec.ts | 0 packages/{ => ui}/acp/tests/turns.spec.ts | 0 packages/ui/acp/tsconfig.json | 36 ++ pnpm-lock.yaml | 364 +++++++++--------- pnpm-workspace.yaml | 2 +- scripts/check-workspace-constraints.ts | 25 +- scripts/doc-typecheck.ts | 21 +- scripts/gen-cordis-catalog.ts | 4 +- scripts/gen-module-graph.ts | 4 +- scripts/publint-all.ts | 39 +- scripts/type-equiv.manifest.json | 62 +-- scripts/verify-md-links.ts | 1 + scripts/verify-md-wrap.ts | 2 +- scripts/verify-type-equiv.ts | 2 +- tsconfig.base.json | 31 +- tsconfig.build.json | 36 +- tsconfig.test.json | 2 +- tsconfig.typecheck.json | 28 +- tsdown.config.ts | 9 +- vitest.config.ts | 6 +- vitest.e2e.config.ts | 2 +- 191 files changed, 822 insertions(+), 624 deletions(-) delete mode 100644 packages/acp/tsconfig.json delete mode 100644 packages/agent-loop/tsconfig.json delete mode 100644 packages/agent/tsconfig.json delete mode 100644 packages/bash-local/tsconfig.json rename packages/{ => bash}/bash-local/README.md (100%) rename packages/{ => bash}/bash-local/package.json (100%) rename packages/{ => bash}/bash-local/src/index.ts (100%) rename packages/{ => bash}/bash-local/src/run.ts (100%) rename packages/{ => bash}/bash-local/tests/executor.spec.ts (100%) rename packages/{ => bash}/bash-local/tests/run.spec.ts (100%) create mode 100644 packages/bash/bash-local/tsconfig.json rename packages/bash/{ => bash}/README.md (100%) rename packages/bash/{ => bash}/package.json (100%) rename packages/bash/{ => bash}/src/index.ts (100%) rename packages/bash/{ => bash}/src/types.ts (100%) rename packages/bash/{ => bash}/tests/service.spec.ts (100%) create mode 100644 packages/bash/bash/tsconfig.json rename packages/{ => bash}/tool-bash/README.md (100%) rename packages/{ => bash}/tool-bash/package.json (100%) rename packages/{ => bash}/tool-bash/src/index.ts (100%) rename packages/{ => bash}/tool-bash/tests/integration.spec.ts (99%) rename packages/{ => bash}/tool-bash/tests/tools.spec.ts (100%) create mode 100644 packages/bash/tool-bash/tsconfig.json delete mode 100644 packages/bash/tsconfig.json rename packages/{ => core}/agent-loop/README.md (100%) rename packages/{ => core}/agent-loop/package.json (100%) rename packages/{ => core}/agent-loop/src/agent.ts (100%) rename packages/{ => core}/agent-loop/src/inbox.ts (100%) rename packages/{ => core}/agent-loop/src/index.ts (100%) rename packages/{ => core}/agent-loop/src/loop.ts (100%) rename packages/{ => core}/agent-loop/tests/agent.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/cancel.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/config-session-id.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/coverage-edges.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/inbox.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/loop.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/mock-adapter.ts (100%) rename packages/{ => core}/agent-loop/tests/properties.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/resume.spec.ts (100%) rename packages/{ => core}/agent-loop/tests/review-fixes.spec.ts (100%) create mode 100644 packages/core/agent-loop/tsconfig.json rename packages/{ => core}/agent/README.md (100%) rename packages/{ => core}/agent/package.json (100%) rename packages/{ => core}/agent/src/index.ts (100%) rename packages/{ => core}/agent/src/types.ts (100%) rename packages/{ => core}/agent/tests/agent.spec.ts (100%) rename packages/{ => core}/agent/tests/gen-cordis-catalog.spec.ts (96%) create mode 100644 packages/core/agent/tsconfig.json rename packages/{ => core}/session/README.md (100%) rename packages/{ => core}/session/package.json (100%) rename packages/{ => core}/session/src/index.ts (100%) rename packages/{ => core}/session/src/json.ts (100%) rename packages/{ => core}/session/src/repair.ts (100%) rename packages/{ => core}/session/src/types.ts (100%) rename packages/{ => core}/session/tests/properties.spec.ts (100%) rename packages/{ => core}/session/tests/repair.spec.ts (100%) rename packages/{ => core}/session/tests/session.spec.ts (100%) create mode 100644 packages/core/session/tsconfig.json rename packages/{ => core}/system-prompt/README.md (100%) rename packages/{ => core}/system-prompt/package.json (100%) rename packages/{ => core}/system-prompt/src/index.ts (100%) rename packages/{ => core}/system-prompt/tests/system-prompt.spec.ts (100%) create mode 100644 packages/core/system-prompt/tsconfig.json rename packages/{ => core}/tools/README.md (100%) rename packages/{ => core}/tools/package.json (100%) rename packages/{ => core}/tools/src/index.ts (100%) rename packages/{ => core}/tools/src/schema.ts (100%) rename packages/{ => core}/tools/tests/properties.spec.ts (100%) rename packages/{ => core}/tools/tests/tools.spec.ts (100%) create mode 100644 packages/core/tools/tsconfig.json delete mode 100644 packages/invariants/tsconfig.json delete mode 100644 packages/llm-deepseek/tsconfig.json delete mode 100644 packages/llm-pi-ai/tsconfig.json delete mode 100644 packages/llm-replay/tsconfig.json rename packages/{ => llm}/llm-deepseek/README.md (100%) rename packages/{ => llm}/llm-deepseek/package.json (100%) rename packages/{ => llm}/llm-deepseek/src/adapter.ts (100%) rename packages/{ => llm}/llm-deepseek/src/index.ts (100%) rename packages/{ => llm}/llm-deepseek/src/serialize.ts (100%) rename packages/{ => llm}/llm-deepseek/src/sse.ts (100%) rename packages/{ => llm}/llm-deepseek/src/translate.ts (100%) rename packages/{ => llm}/llm-deepseek/src/types.ts (100%) rename packages/{ => llm}/llm-deepseek/tests/adapter.e2e.ts (100%) rename packages/{ => llm}/llm-deepseek/tests/adapter.spec.ts (100%) rename packages/{ => llm}/llm-deepseek/tests/serialize.spec.ts (100%) rename packages/{ => llm}/llm-deepseek/tests/sse.spec.ts (100%) rename packages/{ => llm}/llm-deepseek/tests/translate.spec.ts (100%) create mode 100644 packages/llm/llm-deepseek/tsconfig.json rename packages/{ => llm}/llm-pi-ai/README.md (100%) rename packages/{ => llm}/llm-pi-ai/package.json (100%) rename packages/{ => llm}/llm-pi-ai/src/adapter.ts (100%) rename packages/{ => llm}/llm-pi-ai/src/convert.ts (100%) rename packages/{ => llm}/llm-pi-ai/src/index.ts (100%) rename packages/{ => llm}/llm-pi-ai/tests/adapter.e2e.ts (100%) rename packages/{ => llm}/llm-pi-ai/tests/adapter.spec.ts (100%) rename packages/{ => llm}/llm-pi-ai/tests/convert.spec.ts (100%) create mode 100644 packages/llm/llm-pi-ai/tsconfig.json rename packages/llm/{ => llm}/README.md (100%) rename packages/llm/{ => llm}/package.json (100%) rename packages/llm/{ => llm}/src/assembler.ts (100%) rename packages/llm/{ => llm}/src/brand.ts (100%) rename packages/llm/{ => llm}/src/error.ts (100%) rename packages/llm/{ => llm}/src/index.ts (100%) rename packages/llm/{ => llm}/src/never.ts (100%) rename packages/llm/{ => llm}/src/types.ts (100%) rename packages/llm/{ => llm}/tests/assembler.spec.ts (100%) rename packages/llm/{ => llm}/tests/properties.spec.ts (100%) rename packages/llm/{ => llm}/tests/service.spec.ts (100%) create mode 100644 packages/llm/llm/tsconfig.json delete mode 100644 packages/llm/tsconfig.json delete mode 100644 packages/session-persistence-jsonl/tsconfig.json delete mode 100644 packages/session-persistence-sqlite/tsconfig.json rename packages/{ => session-persistence}/session-persistence-jsonl/README.md (100%) rename packages/{ => session-persistence}/session-persistence-jsonl/package.json (100%) rename packages/{ => session-persistence}/session-persistence-jsonl/src/format.ts (100%) rename packages/{ => session-persistence}/session-persistence-jsonl/src/index.ts (100%) rename packages/{ => session-persistence}/session-persistence-jsonl/tests/jsonl.spec.ts (100%) create mode 100644 packages/session-persistence/session-persistence-jsonl/tsconfig.json rename packages/{ => session-persistence}/session-persistence-sqlite/README.md (100%) rename packages/{ => session-persistence}/session-persistence-sqlite/package.json (100%) rename packages/{ => session-persistence}/session-persistence-sqlite/src/index.ts (100%) rename packages/{ => session-persistence}/session-persistence-sqlite/src/schema.ts (100%) rename packages/{ => session-persistence}/session-persistence-sqlite/tests/sqlite.spec.ts (100%) create mode 100644 packages/session-persistence/session-persistence-sqlite/tsconfig.json rename packages/session-persistence/{ => session-persistence}/README.md (100%) rename packages/session-persistence/{ => session-persistence}/package.json (100%) rename packages/session-persistence/{ => session-persistence}/src/coordinator.ts (100%) rename packages/session-persistence/{ => session-persistence}/src/index.ts (100%) rename packages/session-persistence/{ => session-persistence}/tests/contract.ts (100%) rename packages/session-persistence/{ => session-persistence}/tests/coordinator-contract.ts (100%) rename packages/session-persistence/{ => session-persistence}/tests/persistence.spec.ts (100%) create mode 100644 packages/session-persistence/session-persistence/tsconfig.json delete mode 100644 packages/session-persistence/tsconfig.json delete mode 100644 packages/session/tsconfig.json rename packages/{ => support}/invariants/README.md (100%) rename packages/{ => support}/invariants/package.json (100%) rename packages/{ => support}/invariants/src/index.ts (100%) rename packages/{ => support}/invariants/tests/invariants.spec.ts (100%) create mode 100644 packages/support/invariants/tsconfig.json rename packages/{ => support}/llm-replay/README.md (100%) rename packages/{ => support}/llm-replay/package.json (100%) rename packages/{ => support}/llm-replay/src/index.ts (100%) rename packages/{ => support}/llm-replay/tests/llm-replay.spec.ts (100%) create mode 100644 packages/support/llm-replay/tsconfig.json rename packages/{ => support}/ui-stdio/README.md (100%) rename packages/{ => support}/ui-stdio/package.json (100%) rename packages/{ => support}/ui-stdio/src/index.ts (100%) rename packages/{ => support}/ui-stdio/tests/ui-stdio.spec.ts (100%) create mode 100644 packages/support/ui-stdio/tsconfig.json delete mode 100644 packages/system-prompt/tsconfig.json delete mode 100644 packages/tool-bash/tsconfig.json delete mode 100644 packages/tools/tsconfig.json delete mode 100644 packages/ui-stdio/tsconfig.json rename packages/{ => ui}/acp/README.md (100%) rename packages/{ => ui}/acp/acp-feature-support.md (100%) rename packages/{ => ui}/acp/package.json (100%) rename packages/{ => ui}/acp/src/codec.ts (100%) rename packages/{ => ui}/acp/src/index.ts (100%) rename packages/{ => ui}/acp/tests/bridge.spec.ts (100%) rename packages/{ => ui}/acp/tests/codec.spec.ts (100%) rename packages/{ => ui}/acp/tests/dispose.spec.ts (100%) rename packages/{ => ui}/acp/tests/edges.spec.ts (100%) rename packages/{ => ui}/acp/tests/harness.ts (100%) rename packages/{ => ui}/acp/tests/load.spec.ts (100%) rename packages/{ => ui}/acp/tests/multi-session.spec.ts (100%) rename packages/{ => ui}/acp/tests/properties.spec.ts (100%) rename packages/{ => ui}/acp/tests/stream-update.spec.ts (100%) rename packages/{ => ui}/acp/tests/turns.spec.ts (100%) create mode 100644 packages/ui/acp/tsconfig.json diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e34a2321d1..d6ca9f9850 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/agent/src/types.ts:140`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:140`](../../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/agent/src/types.ts:146`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:146`](../../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/agent/src/types.ts:223`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:159`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:192`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:153`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:217`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:183`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:198`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:198`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:178`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/agent/src/types.ts:212`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:205`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/agent/src/types.ts:172`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/types.ts:166`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -193,7 +193,7 @@ An adapter was registered or unregistered (the model→adapter map changed). 'llm/adapter-change'(): void ``` -Source: [`packages/llm/src/index.ts:43`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) #### `llm/generate` — waterfall @@ -205,7 +205,7 @@ Waterfall around every non-streaming model call. Bound to the LlmService; call ` Types: [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) -Source: [`packages/llm/src/index.ts:38`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:38`](../../packages/llm/llm/src/index.ts) #### `llm/stream` — waterfall @@ -217,7 +217,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/src/index.ts:32`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts) ### `session/*` @@ -229,7 +229,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/session/src/index.ts:30`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:30`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -241,7 +241,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/session/src/index.ts:36`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -251,7 +251,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/session/src/index.ts:45`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts) ### `system-prompt/*` @@ -263,7 +263,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise ``` -Source: [`packages/system-prompt/src/index.ts:24`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:24`](../../packages/core/system-prompt/src/index.ts) #### `system-prompt/change` — emit @@ -273,7 +273,7 @@ A section or tool provider was registered or unregistered (the assembly inputs c 'system-prompt/change'(): void ``` -Source: [`packages/system-prompt/src/index.ts:30`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts) ### `tools/*` @@ -285,7 +285,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/tools/src/index.ts:48`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:48`](../../packages/core/tools/src/index.ts) #### `tools/execute` — waterfall @@ -297,7 +297,7 @@ Waterfall around every tool execution — the single seam where sandbox, permiss Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/tools/src/index.ts:43`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) ## Services @@ -315,7 +315,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/agent-loop/src/index.ts:60`](../../packages/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -332,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/agent/src/index.ts:105`](../../packages/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) @@ -359,7 +359,7 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/src/index.ts:58`](../../packages/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/index.ts) ### `ctx.llm` — `LlmService` @@ -375,7 +375,7 @@ generate(options: GenerateOptions): Promise Types: [ContentBlock](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/src/index.ts:81`](../../packages/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:81`](../../packages/llm/llm/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -399,7 +399,7 @@ abstract delete(id: SessionId): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/src/index.ts:98`](../../packages/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/session-persistence/src/index.ts) ### `ctx.sessions` — `SessionStore` @@ -416,7 +416,7 @@ get(id: string): Session | undefined list(): Session[] ``` -Source: [`packages/session/src/index.ts:222`](../../packages/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:222`](../../packages/core/session/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` @@ -428,7 +428,7 @@ tools(provider: () => ToolSchema[]): () => void assemble(): Promise ``` -Source: [`packages/system-prompt/src/index.ts:71`](../../packages/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` @@ -443,7 +443,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/tools/src/index.ts:277`](../../packages/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:277`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/eslint.config.mjs b/eslint.config.mjs index a4f48af798..d4763d8377 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,7 +30,7 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], + files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -81,7 +81,7 @@ export default tseslint.config( // --- tests: same rules, minus the friction that fights test ergonomics -- { - files: ['packages/*/tests/**/*.ts'], + files: ['packages/*/*/tests/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], diff --git a/knip.json b/knip.json index f0ca39d57e..9ebeeb5caa 100644 --- a/knip.json +++ b/knip.json @@ -14,15 +14,15 @@ ], "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, - "packages/*": { + "packages/*/*": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/llm-deepseek": { + "packages/llm/llm-deepseek": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/llm-pi-ai": { + "packages/llm/llm-pi-ai": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/package.json b/package.json index eeb5dd7d07..e5867067c6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "workspaces": [ "vendor/*", - "packages/*" + "packages/*/*" ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", diff --git a/packages/acp/tsconfig.json b/packages/acp/tsconfig.json deleted file mode 100644 index 83330256e3..0000000000 --- a/packages/acp/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../agent" }, - { "path": "../tools" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/agent-loop/tsconfig.json b/packages/agent-loop/tsconfig.json deleted file mode 100644 index 6751664d5c..0000000000 --- a/packages/agent-loop/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../session-persistence" }, - { "path": "../system-prompt" }, - { "path": "../tools" }, - { "path": "../agent" } - ] -} diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json deleted file mode 100644 index 0806132292..0000000000 --- a/packages/agent/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/bash-local/tsconfig.json b/packages/bash-local/tsconfig.json deleted file mode 100644 index a657d8bf8e..0000000000 --- a/packages/bash-local/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../bash" } - ] -} diff --git a/packages/bash-local/README.md b/packages/bash/bash-local/README.md similarity index 100% rename from packages/bash-local/README.md rename to packages/bash/bash-local/README.md diff --git a/packages/bash-local/package.json b/packages/bash/bash-local/package.json similarity index 100% rename from packages/bash-local/package.json rename to packages/bash/bash-local/package.json diff --git a/packages/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts similarity index 100% rename from packages/bash-local/src/index.ts rename to packages/bash/bash-local/src/index.ts diff --git a/packages/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts similarity index 100% rename from packages/bash-local/src/run.ts rename to packages/bash/bash-local/src/run.ts diff --git a/packages/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts similarity index 100% rename from packages/bash-local/tests/executor.spec.ts rename to packages/bash/bash-local/tests/executor.spec.ts diff --git a/packages/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts similarity index 100% rename from packages/bash-local/tests/run.spec.ts rename to packages/bash/bash-local/tests/run.spec.ts diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json new file mode 100644 index 0000000000..1c27a33a89 --- /dev/null +++ b/packages/bash/bash-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/bash/README.md b/packages/bash/bash/README.md similarity index 100% rename from packages/bash/README.md rename to packages/bash/bash/README.md diff --git a/packages/bash/package.json b/packages/bash/bash/package.json similarity index 100% rename from packages/bash/package.json rename to packages/bash/bash/package.json diff --git a/packages/bash/src/index.ts b/packages/bash/bash/src/index.ts similarity index 100% rename from packages/bash/src/index.ts rename to packages/bash/bash/src/index.ts diff --git a/packages/bash/src/types.ts b/packages/bash/bash/src/types.ts similarity index 100% rename from packages/bash/src/types.ts rename to packages/bash/bash/src/types.ts diff --git a/packages/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts similarity index 100% rename from packages/bash/tests/service.spec.ts rename to packages/bash/bash/tests/service.spec.ts diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json new file mode 100644 index 0000000000..10dabc415e --- /dev/null +++ b/packages/bash/bash/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/tool-bash/README.md b/packages/bash/tool-bash/README.md similarity index 100% rename from packages/tool-bash/README.md rename to packages/bash/tool-bash/README.md diff --git a/packages/tool-bash/package.json b/packages/bash/tool-bash/package.json similarity index 100% rename from packages/tool-bash/package.json rename to packages/bash/tool-bash/package.json diff --git a/packages/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts similarity index 100% rename from packages/tool-bash/src/index.ts rename to packages/bash/tool-bash/src/index.ts diff --git a/packages/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts similarity index 99% rename from packages/tool-bash/tests/integration.spec.ts rename to packages/bash/tool-bash/tests/integration.spec.ts index d31b3a5a7e..0ab786ca85 100644 --- a/packages/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** * Full-loop integration: a scripted mock model drives the REAL bash tool diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts similarity index 100% rename from packages/tool-bash/tests/tools.spec.ts rename to packages/bash/tool-bash/tests/tools.spec.ts diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json new file mode 100644 index 0000000000..6cd94d1d9a --- /dev/null +++ b/packages/bash/tool-bash/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/bash/tsconfig.json b/packages/bash/tsconfig.json deleted file mode 100644 index 2617271c44..0000000000 --- a/packages/bash/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" } - ] -} diff --git a/packages/agent-loop/README.md b/packages/core/agent-loop/README.md similarity index 100% rename from packages/agent-loop/README.md rename to packages/core/agent-loop/README.md diff --git a/packages/agent-loop/package.json b/packages/core/agent-loop/package.json similarity index 100% rename from packages/agent-loop/package.json rename to packages/core/agent-loop/package.json diff --git a/packages/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts similarity index 100% rename from packages/agent-loop/src/agent.ts rename to packages/core/agent-loop/src/agent.ts diff --git a/packages/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts similarity index 100% rename from packages/agent-loop/src/inbox.ts rename to packages/core/agent-loop/src/inbox.ts diff --git a/packages/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts similarity index 100% rename from packages/agent-loop/src/index.ts rename to packages/core/agent-loop/src/index.ts diff --git a/packages/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts similarity index 100% rename from packages/agent-loop/src/loop.ts rename to packages/core/agent-loop/src/loop.ts diff --git a/packages/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts similarity index 100% rename from packages/agent-loop/tests/agent.spec.ts rename to packages/core/agent-loop/tests/agent.spec.ts diff --git a/packages/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts similarity index 100% rename from packages/agent-loop/tests/cancel.spec.ts rename to packages/core/agent-loop/tests/cancel.spec.ts diff --git a/packages/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts similarity index 100% rename from packages/agent-loop/tests/config-session-id.spec.ts rename to packages/core/agent-loop/tests/config-session-id.spec.ts diff --git a/packages/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts similarity index 100% rename from packages/agent-loop/tests/coverage-edges.spec.ts rename to packages/core/agent-loop/tests/coverage-edges.spec.ts diff --git a/packages/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts similarity index 100% rename from packages/agent-loop/tests/inbox.spec.ts rename to packages/core/agent-loop/tests/inbox.spec.ts diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts similarity index 100% rename from packages/agent-loop/tests/loop.spec.ts rename to packages/core/agent-loop/tests/loop.spec.ts diff --git a/packages/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts similarity index 100% rename from packages/agent-loop/tests/mock-adapter.ts rename to packages/core/agent-loop/tests/mock-adapter.ts diff --git a/packages/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts similarity index 100% rename from packages/agent-loop/tests/properties.spec.ts rename to packages/core/agent-loop/tests/properties.spec.ts diff --git a/packages/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts similarity index 100% rename from packages/agent-loop/tests/resume.spec.ts rename to packages/core/agent-loop/tests/resume.spec.ts diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts similarity index 100% rename from packages/agent-loop/tests/review-fixes.spec.ts rename to packages/core/agent-loop/tests/review-fixes.spec.ts diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json new file mode 100644 index 0000000000..e8a471a08c --- /dev/null +++ b/packages/core/agent-loop/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/agent/README.md b/packages/core/agent/README.md similarity index 100% rename from packages/agent/README.md rename to packages/core/agent/README.md diff --git a/packages/agent/package.json b/packages/core/agent/package.json similarity index 100% rename from packages/agent/package.json rename to packages/core/agent/package.json diff --git a/packages/agent/src/index.ts b/packages/core/agent/src/index.ts similarity index 100% rename from packages/agent/src/index.ts rename to packages/core/agent/src/index.ts diff --git a/packages/agent/src/types.ts b/packages/core/agent/src/types.ts similarity index 100% rename from packages/agent/src/types.ts rename to packages/core/agent/src/types.ts diff --git a/packages/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts similarity index 100% rename from packages/agent/tests/agent.spec.ts rename to packages/core/agent/tests/agent.spec.ts diff --git a/packages/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts similarity index 96% rename from packages/agent/tests/gen-cordis-catalog.spec.ts rename to packages/core/agent/tests/gen-cordis-catalog.spec.ts index 66edb9983f..ee2ce47699 100644 --- a/packages/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -14,13 +14,13 @@ 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 } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ function fixtureRoot(eventsBlock: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) - const dir = join(root, 'packages', 'fix', 'src') + const dir = join(root, 'packages', 'group', 'fix', 'src') mkdirSync(dir, { recursive: true }) writeFileSync( join(dir, 'index.ts'), diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json new file mode 100644 index 0000000000..e7d274f2cd --- /dev/null +++ b/packages/core/agent/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session/README.md b/packages/core/session/README.md similarity index 100% rename from packages/session/README.md rename to packages/core/session/README.md diff --git a/packages/session/package.json b/packages/core/session/package.json similarity index 100% rename from packages/session/package.json rename to packages/core/session/package.json diff --git a/packages/session/src/index.ts b/packages/core/session/src/index.ts similarity index 100% rename from packages/session/src/index.ts rename to packages/core/session/src/index.ts diff --git a/packages/session/src/json.ts b/packages/core/session/src/json.ts similarity index 100% rename from packages/session/src/json.ts rename to packages/core/session/src/json.ts diff --git a/packages/session/src/repair.ts b/packages/core/session/src/repair.ts similarity index 100% rename from packages/session/src/repair.ts rename to packages/core/session/src/repair.ts diff --git a/packages/session/src/types.ts b/packages/core/session/src/types.ts similarity index 100% rename from packages/session/src/types.ts rename to packages/core/session/src/types.ts diff --git a/packages/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts similarity index 100% rename from packages/session/tests/properties.spec.ts rename to packages/core/session/tests/properties.spec.ts diff --git a/packages/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts similarity index 100% rename from packages/session/tests/repair.spec.ts rename to packages/core/session/tests/repair.spec.ts diff --git a/packages/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts similarity index 100% rename from packages/session/tests/session.spec.ts rename to packages/core/session/tests/session.spec.ts diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json new file mode 100644 index 0000000000..3423a0e06c --- /dev/null +++ b/packages/core/session/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/system-prompt/README.md b/packages/core/system-prompt/README.md similarity index 100% rename from packages/system-prompt/README.md rename to packages/core/system-prompt/README.md diff --git a/packages/system-prompt/package.json b/packages/core/system-prompt/package.json similarity index 100% rename from packages/system-prompt/package.json rename to packages/core/system-prompt/package.json diff --git a/packages/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts similarity index 100% rename from packages/system-prompt/src/index.ts rename to packages/core/system-prompt/src/index.ts diff --git a/packages/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts similarity index 100% rename from packages/system-prompt/tests/system-prompt.spec.ts rename to packages/core/system-prompt/tests/system-prompt.spec.ts diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json new file mode 100644 index 0000000000..3423a0e06c --- /dev/null +++ b/packages/core/system-prompt/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/tools/README.md b/packages/core/tools/README.md similarity index 100% rename from packages/tools/README.md rename to packages/core/tools/README.md diff --git a/packages/tools/package.json b/packages/core/tools/package.json similarity index 100% rename from packages/tools/package.json rename to packages/core/tools/package.json diff --git a/packages/tools/src/index.ts b/packages/core/tools/src/index.ts similarity index 100% rename from packages/tools/src/index.ts rename to packages/core/tools/src/index.ts diff --git a/packages/tools/src/schema.ts b/packages/core/tools/src/schema.ts similarity index 100% rename from packages/tools/src/schema.ts rename to packages/core/tools/src/schema.ts diff --git a/packages/tools/tests/properties.spec.ts b/packages/core/tools/tests/properties.spec.ts similarity index 100% rename from packages/tools/tests/properties.spec.ts rename to packages/core/tools/tests/properties.spec.ts diff --git a/packages/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts similarity index 100% rename from packages/tools/tests/tools.spec.ts rename to packages/core/tools/tests/tools.spec.ts diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json new file mode 100644 index 0000000000..27219e926d --- /dev/null +++ b/packages/core/tools/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/invariants/tsconfig.json b/packages/invariants/tsconfig.json deleted file mode 100644 index 54fbb4adac..0000000000 --- a/packages/invariants/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" }, - { "path": "../agent" } - ] -} diff --git a/packages/llm-deepseek/tsconfig.json b/packages/llm-deepseek/tsconfig.json deleted file mode 100644 index eea89a4aac..0000000000 --- a/packages/llm-deepseek/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" } - ] -} diff --git a/packages/llm-pi-ai/tsconfig.json b/packages/llm-pi-ai/tsconfig.json deleted file mode 100644 index eea89a4aac..0000000000 --- a/packages/llm-pi-ai/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../llm" } - ] -} diff --git a/packages/llm-replay/tsconfig.json b/packages/llm-replay/tsconfig.json deleted file mode 100644 index 0806132292..0000000000 --- a/packages/llm-replay/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md similarity index 100% rename from packages/llm-deepseek/README.md rename to packages/llm/llm-deepseek/README.md diff --git a/packages/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json similarity index 100% rename from packages/llm-deepseek/package.json rename to packages/llm/llm-deepseek/package.json diff --git a/packages/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts similarity index 100% rename from packages/llm-deepseek/src/adapter.ts rename to packages/llm/llm-deepseek/src/adapter.ts diff --git a/packages/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts similarity index 100% rename from packages/llm-deepseek/src/index.ts rename to packages/llm/llm-deepseek/src/index.ts diff --git a/packages/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts similarity index 100% rename from packages/llm-deepseek/src/serialize.ts rename to packages/llm/llm-deepseek/src/serialize.ts diff --git a/packages/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts similarity index 100% rename from packages/llm-deepseek/src/sse.ts rename to packages/llm/llm-deepseek/src/sse.ts diff --git a/packages/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts similarity index 100% rename from packages/llm-deepseek/src/translate.ts rename to packages/llm/llm-deepseek/src/translate.ts diff --git a/packages/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts similarity index 100% rename from packages/llm-deepseek/src/types.ts rename to packages/llm/llm-deepseek/src/types.ts diff --git a/packages/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts similarity index 100% rename from packages/llm-deepseek/tests/adapter.e2e.ts rename to packages/llm/llm-deepseek/tests/adapter.e2e.ts diff --git a/packages/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/adapter.spec.ts rename to packages/llm/llm-deepseek/tests/adapter.spec.ts diff --git a/packages/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/serialize.spec.ts rename to packages/llm/llm-deepseek/tests/serialize.spec.ts diff --git a/packages/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/sse.spec.ts rename to packages/llm/llm-deepseek/tests/sse.spec.ts diff --git a/packages/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts similarity index 100% rename from packages/llm-deepseek/tests/translate.spec.ts rename to packages/llm/llm-deepseek/tests/translate.spec.ts diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json new file mode 100644 index 0000000000..b187cddf35 --- /dev/null +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md similarity index 100% rename from packages/llm-pi-ai/README.md rename to packages/llm/llm-pi-ai/README.md diff --git a/packages/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json similarity index 100% rename from packages/llm-pi-ai/package.json rename to packages/llm/llm-pi-ai/package.json diff --git a/packages/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts similarity index 100% rename from packages/llm-pi-ai/src/adapter.ts rename to packages/llm/llm-pi-ai/src/adapter.ts diff --git a/packages/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts similarity index 100% rename from packages/llm-pi-ai/src/convert.ts rename to packages/llm/llm-pi-ai/src/convert.ts diff --git a/packages/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts similarity index 100% rename from packages/llm-pi-ai/src/index.ts rename to packages/llm/llm-pi-ai/src/index.ts diff --git a/packages/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts similarity index 100% rename from packages/llm-pi-ai/tests/adapter.e2e.ts rename to packages/llm/llm-pi-ai/tests/adapter.e2e.ts diff --git a/packages/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts similarity index 100% rename from packages/llm-pi-ai/tests/adapter.spec.ts rename to packages/llm/llm-pi-ai/tests/adapter.spec.ts diff --git a/packages/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts similarity index 100% rename from packages/llm-pi-ai/tests/convert.spec.ts rename to packages/llm/llm-pi-ai/tests/convert.spec.ts diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json new file mode 100644 index 0000000000..b187cddf35 --- /dev/null +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/llm/README.md b/packages/llm/llm/README.md similarity index 100% rename from packages/llm/README.md rename to packages/llm/llm/README.md diff --git a/packages/llm/package.json b/packages/llm/llm/package.json similarity index 100% rename from packages/llm/package.json rename to packages/llm/llm/package.json diff --git a/packages/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts similarity index 100% rename from packages/llm/src/assembler.ts rename to packages/llm/llm/src/assembler.ts diff --git a/packages/llm/src/brand.ts b/packages/llm/llm/src/brand.ts similarity index 100% rename from packages/llm/src/brand.ts rename to packages/llm/llm/src/brand.ts diff --git a/packages/llm/src/error.ts b/packages/llm/llm/src/error.ts similarity index 100% rename from packages/llm/src/error.ts rename to packages/llm/llm/src/error.ts diff --git a/packages/llm/src/index.ts b/packages/llm/llm/src/index.ts similarity index 100% rename from packages/llm/src/index.ts rename to packages/llm/llm/src/index.ts diff --git a/packages/llm/src/never.ts b/packages/llm/llm/src/never.ts similarity index 100% rename from packages/llm/src/never.ts rename to packages/llm/llm/src/never.ts diff --git a/packages/llm/src/types.ts b/packages/llm/llm/src/types.ts similarity index 100% rename from packages/llm/src/types.ts rename to packages/llm/llm/src/types.ts diff --git a/packages/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts similarity index 100% rename from packages/llm/tests/assembler.spec.ts rename to packages/llm/llm/tests/assembler.spec.ts diff --git a/packages/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts similarity index 100% rename from packages/llm/tests/properties.spec.ts rename to packages/llm/llm/tests/properties.spec.ts diff --git a/packages/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts similarity index 100% rename from packages/llm/tests/service.spec.ts rename to packages/llm/llm/tests/service.spec.ts diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json new file mode 100644 index 0000000000..10dabc415e --- /dev/null +++ b/packages/llm/llm/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json deleted file mode 100644 index 2617271c44..0000000000 --- a/packages/llm/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" } - ] -} diff --git a/packages/session-persistence-jsonl/tsconfig.json b/packages/session-persistence-jsonl/tsconfig.json deleted file mode 100644 index 3595f989bd..0000000000 --- a/packages/session-persistence-jsonl/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../session" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/session-persistence-sqlite/tsconfig.json b/packages/session-persistence-sqlite/tsconfig.json deleted file mode 100644 index 3595f989bd..0000000000 --- a/packages/session-persistence-sqlite/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../session" }, - { "path": "../session-persistence" } - ] -} diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md similarity index 100% rename from packages/session-persistence-jsonl/README.md rename to packages/session-persistence/session-persistence-jsonl/README.md diff --git a/packages/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json similarity index 100% rename from packages/session-persistence-jsonl/package.json rename to packages/session-persistence/session-persistence-jsonl/package.json diff --git a/packages/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts similarity index 100% rename from packages/session-persistence-jsonl/src/format.ts rename to packages/session-persistence/session-persistence-jsonl/src/format.ts diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts similarity index 100% rename from packages/session-persistence-jsonl/src/index.ts rename to packages/session-persistence/session-persistence-jsonl/src/index.ts diff --git a/packages/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts similarity index 100% rename from packages/session-persistence-jsonl/tests/jsonl.spec.ts rename to packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts diff --git a/packages/session-persistence/session-persistence-jsonl/tsconfig.json b/packages/session-persistence/session-persistence-jsonl/tsconfig.json new file mode 100644 index 0000000000..adb2824e27 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/packages/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md similarity index 100% rename from packages/session-persistence-sqlite/README.md rename to packages/session-persistence/session-persistence-sqlite/README.md diff --git a/packages/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json similarity index 100% rename from packages/session-persistence-sqlite/package.json rename to packages/session-persistence/session-persistence-sqlite/package.json diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts similarity index 100% rename from packages/session-persistence-sqlite/src/index.ts rename to packages/session-persistence/session-persistence-sqlite/src/index.ts diff --git a/packages/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts similarity index 100% rename from packages/session-persistence-sqlite/src/schema.ts rename to packages/session-persistence/session-persistence-sqlite/src/schema.ts diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts similarity index 100% rename from packages/session-persistence-sqlite/tests/sqlite.spec.ts rename to packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts diff --git a/packages/session-persistence/session-persistence-sqlite/tsconfig.json b/packages/session-persistence/session-persistence-sqlite/tsconfig.json new file mode 100644 index 0000000000..adb2824e27 --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/packages/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md similarity index 100% rename from packages/session-persistence/README.md rename to packages/session-persistence/session-persistence/README.md diff --git a/packages/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json similarity index 100% rename from packages/session-persistence/package.json rename to packages/session-persistence/session-persistence/package.json diff --git a/packages/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts similarity index 100% rename from packages/session-persistence/src/coordinator.ts rename to packages/session-persistence/session-persistence/src/coordinator.ts diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts similarity index 100% rename from packages/session-persistence/src/index.ts rename to packages/session-persistence/session-persistence/src/index.ts diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts similarity index 100% rename from packages/session-persistence/tests/contract.ts rename to packages/session-persistence/session-persistence/tests/contract.ts diff --git a/packages/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts similarity index 100% rename from packages/session-persistence/tests/coordinator-contract.ts rename to packages/session-persistence/session-persistence/tests/coordinator-contract.ts diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts similarity index 100% rename from packages/session-persistence/tests/persistence.spec.ts rename to packages/session-persistence/session-persistence/tests/persistence.spec.ts diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json new file mode 100644 index 0000000000..df07556965 --- /dev/null +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session-persistence/tsconfig.json b/packages/session-persistence/tsconfig.json deleted file mode 100644 index 727294a720..0000000000 --- a/packages/session-persistence/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../session" } - ] -} diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json deleted file mode 100644 index e226412a53..0000000000 --- a/packages/session/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" } - ] -} diff --git a/packages/invariants/README.md b/packages/support/invariants/README.md similarity index 100% rename from packages/invariants/README.md rename to packages/support/invariants/README.md diff --git a/packages/invariants/package.json b/packages/support/invariants/package.json similarity index 100% rename from packages/invariants/package.json rename to packages/support/invariants/package.json diff --git a/packages/invariants/src/index.ts b/packages/support/invariants/src/index.ts similarity index 100% rename from packages/invariants/src/index.ts rename to packages/support/invariants/src/index.ts diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts similarity index 100% rename from packages/invariants/tests/invariants.spec.ts rename to packages/support/invariants/tests/invariants.spec.ts diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json new file mode 100644 index 0000000000..76021c9ae5 --- /dev/null +++ b/packages/support/invariants/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/llm-replay/README.md b/packages/support/llm-replay/README.md similarity index 100% rename from packages/llm-replay/README.md rename to packages/support/llm-replay/README.md diff --git a/packages/llm-replay/package.json b/packages/support/llm-replay/package.json similarity index 100% rename from packages/llm-replay/package.json rename to packages/support/llm-replay/package.json diff --git a/packages/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts similarity index 100% rename from packages/llm-replay/src/index.ts rename to packages/support/llm-replay/src/index.ts diff --git a/packages/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts similarity index 100% rename from packages/llm-replay/tests/llm-replay.spec.ts rename to packages/support/llm-replay/tests/llm-replay.spec.ts diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json new file mode 100644 index 0000000000..e7d274f2cd --- /dev/null +++ b/packages/support/llm-replay/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/ui-stdio/README.md b/packages/support/ui-stdio/README.md similarity index 100% rename from packages/ui-stdio/README.md rename to packages/support/ui-stdio/README.md diff --git a/packages/ui-stdio/package.json b/packages/support/ui-stdio/package.json similarity index 100% rename from packages/ui-stdio/package.json rename to packages/support/ui-stdio/package.json diff --git a/packages/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts similarity index 100% rename from packages/ui-stdio/src/index.ts rename to packages/support/ui-stdio/src/index.ts diff --git a/packages/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts similarity index 100% rename from packages/ui-stdio/tests/ui-stdio.spec.ts rename to packages/support/ui-stdio/tests/ui-stdio.spec.ts diff --git a/packages/support/ui-stdio/tsconfig.json b/packages/support/ui-stdio/tsconfig.json new file mode 100644 index 0000000000..f7e9736f77 --- /dev/null +++ b/packages/support/ui-stdio/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "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/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json deleted file mode 100644 index e226412a53..0000000000 --- a/packages/system-prompt/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" } - ] -} diff --git a/packages/tool-bash/tsconfig.json b/packages/tool-bash/tsconfig.json deleted file mode 100644 index 4741cb67f3..0000000000 --- a/packages/tool-bash/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../tools" }, - { "path": "../agent" }, - { "path": "../bash" } - ] -} diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json deleted file mode 100644 index 8e29228fc8..0000000000 --- a/packages/tools/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../llm" }, - { "path": "../system-prompt" }, - { "path": "../agent" } - ] -} diff --git a/packages/ui-stdio/tsconfig.json b/packages/ui-stdio/tsconfig.json deleted file mode 100644 index 33fa338e5f..0000000000 --- a/packages/ui-stdio/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src"], - "references": [ - { "path": "../../vendor/cosmokit" }, - { "path": "../../vendor/cordis" }, - { "path": "../../vendor/schemastery" }, - { "path": "../agent" }, - { "path": "../llm" }, - { "path": "../session" } - ] -} diff --git a/packages/acp/README.md b/packages/ui/acp/README.md similarity index 100% rename from packages/acp/README.md rename to packages/ui/acp/README.md diff --git a/packages/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md similarity index 100% rename from packages/acp/acp-feature-support.md rename to packages/ui/acp/acp-feature-support.md diff --git a/packages/acp/package.json b/packages/ui/acp/package.json similarity index 100% rename from packages/acp/package.json rename to packages/ui/acp/package.json diff --git a/packages/acp/src/codec.ts b/packages/ui/acp/src/codec.ts similarity index 100% rename from packages/acp/src/codec.ts rename to packages/ui/acp/src/codec.ts diff --git a/packages/acp/src/index.ts b/packages/ui/acp/src/index.ts similarity index 100% rename from packages/acp/src/index.ts rename to packages/ui/acp/src/index.ts diff --git a/packages/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts similarity index 100% rename from packages/acp/tests/bridge.spec.ts rename to packages/ui/acp/tests/bridge.spec.ts diff --git a/packages/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts similarity index 100% rename from packages/acp/tests/codec.spec.ts rename to packages/ui/acp/tests/codec.spec.ts diff --git a/packages/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts similarity index 100% rename from packages/acp/tests/dispose.spec.ts rename to packages/ui/acp/tests/dispose.spec.ts diff --git a/packages/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts similarity index 100% rename from packages/acp/tests/edges.spec.ts rename to packages/ui/acp/tests/edges.spec.ts diff --git a/packages/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts similarity index 100% rename from packages/acp/tests/harness.ts rename to packages/ui/acp/tests/harness.ts diff --git a/packages/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts similarity index 100% rename from packages/acp/tests/load.spec.ts rename to packages/ui/acp/tests/load.spec.ts diff --git a/packages/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts similarity index 100% rename from packages/acp/tests/multi-session.spec.ts rename to packages/ui/acp/tests/multi-session.spec.ts diff --git a/packages/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts similarity index 100% rename from packages/acp/tests/properties.spec.ts rename to packages/ui/acp/tests/properties.spec.ts diff --git a/packages/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts similarity index 100% rename from packages/acp/tests/stream-update.spec.ts rename to packages/ui/acp/tests/stream-update.spec.ts diff --git a/packages/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts similarity index 100% rename from packages/acp/tests/turns.spec.ts rename to packages/ui/acp/tests/turns.spec.ts diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json new file mode 100644 index 0000000000..33d4d0b6f7 --- /dev/null +++ b/packages/ui/acp/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5026104cd9..8e79f0d6ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,105 +66,13 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/acp: - dependencies: - '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - zod: - specifier: ^4.0.0 - version: 4.4.3 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../agent-loop - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../session-persistence - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../session-persistence-jsonl - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../tool-bash - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/agent: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../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/agent-loop: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../session-persistence - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../session-persistence-jsonl - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/bash: + packages/bash/bash: devDependencies: 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/bash-local: + packages/bash/bash-local: dependencies: schemastery: specifier: ^3.18.0 @@ -177,14 +85,41 @@ 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/invariants: + packages/bash/tool-bash: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ - version: link:../agent + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local '@deepseek-ai/dsh-llm': specifier: workspace:^ - version: link:../llm + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/agent: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session @@ -192,13 +127,80 @@ 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/llm: + packages/core/agent-loop: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/core/session: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + 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/core/system-prompt: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + 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/core/tools: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + 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/llm/llm: devDependencies: 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/llm-deepseek: + packages/llm/llm-deepseek: dependencies: schemastery: specifier: ^3.18.0 @@ -211,7 +213,7 @@ 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/llm-pi-ai: + packages/llm/llm-pi-ai: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 @@ -230,37 +232,16 @@ 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/llm-replay: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../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/session: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - 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/session-persistence: + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + 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/session-persistence-jsonl: + packages/session-persistence/session-persistence-jsonl: dependencies: schemastery: specifier: ^3.18.0 @@ -268,7 +249,7 @@ importers: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence @@ -276,7 +257,7 @@ 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/session-persistence-sqlite: + packages/session-persistence/session-persistence-sqlite: dependencies: schemastery: specifier: ^3.18.0 @@ -284,7 +265,7 @@ importers: devDependencies: '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence @@ -292,75 +273,94 @@ 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/system-prompt: - devDependencies: - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - 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/tool-bash: + packages/support/invariants: devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ - version: link:../agent + 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/support/llm-replay: + devDependencies: + '@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/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/ui/acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.25.1 + version: 0.25.1(zod@4.4.3) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ - version: link:../agent-loop - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../bash + version: link:../../core/agent-loop '@deepseek-ai/dsh-bash-local': specifier: workspace:^ - version: link:../bash-local + version: link:../../bash/bash-local '@deepseek-ai/dsh-llm': specifier: workspace:^ - version: link:../llm + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../session + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ - version: link:../system-prompt + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash '@deepseek-ai/dsh-tools': specifier: workspace:^ - version: link:../tools - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - - packages/tools: - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../system-prompt - 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/ui-stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -2827,7 +2827,7 @@ snapshots: '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.7 '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.7.8 '@smithy/types': 4.14.4 tslib: 2.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f957141fe..b2b731fc58 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - vendor/* - - packages/* + - packages/*/* peerDependencyRules: allowedVersions: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 50b1a80078..3ec4920ad9 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -9,7 +9,12 @@ import { readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') -const workspaceGlobs = ['vendor', 'packages'] as const +// vendor/* is single-level; packages// nests one level deeper +// (the group dirs — core/llm/bash/… — are pure containers with no manifest). +const workspaceGlobs = [ + { dir: 'vendor', depth: 1 }, + { dir: 'packages', depth: 2 }, +] as const const vendoredPackages = new Set([ 'cordis', 'cosmokit', @@ -42,15 +47,25 @@ function readJson(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } +/** Repo-relative dirs holding a package.json, walked to the configured depth. */ +function packageDirs(base: string, depth: number): string[] { + if (depth === 1) { + return readdirSync(join(root, base), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => join(base, entry.name)) + } + return readdirSync(join(root, base), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .flatMap(group => packageDirs(join(base, group.name), depth - 1)) +} + function workspaceManifests(): WorkspaceManifest[] { const manifests: WorkspaceManifest[] = [ { dir: '.', manifest: readJson(join(root, 'package.json')) }, ] - for (const workspaceDir of workspaceGlobs) { - for (const entry of readdirSync(join(root, workspaceDir), { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const dir = join(workspaceDir, entry.name) + for (const { dir: base, depth } of workspaceGlobs) { + for (const dir of packageDirs(base, depth)) { manifests.push({ dir, manifest: readJson(join(root, dir, 'package.json')) }) } } diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 905adfb630..01a64b0eda 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -24,6 +24,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { glob } from 'node:fs/promises' +import ts from 'typescript' const root = resolve(import.meta.dirname, '..') @@ -96,13 +97,17 @@ function extractBlocks(absPath: string): Block[] { * vendor `lib/` to exist (a fresh clone runs `pnpm run build` first; CI does too). */ function workspacePaths(): Record { - const raw = readFileSync(join(root, 'tsconfig.typecheck.json'), 'utf8') - // Strip // line comments and /* */ block comments so JSON.parse accepts it. - const stripped = raw - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/.*$/gm, '$1') - return (JSON.parse(stripped) as { compilerOptions: { paths: Record } }) - .compilerOptions.paths + const file = join(root, 'tsconfig.typecheck.json') + // Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip: + // a regex strip mistakes the `/*/` in a wildcard path candidate + // (`./packages/core/*/src`) for a block comment and corrupts the map. + const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + } + // `config` is typed `any` by the TS API; narrow it to the one field we read. + const config = result.config as { compilerOptions: { paths: Record } } + return config.compilerOptions.paths } /** The standalone tsconfig for the temp project (copies base resolution, no @@ -125,7 +130,7 @@ function tempTsconfig(): string { }) } -const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md'] +const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] const files: string[] = [] for (const pattern of markdownGlobs) { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 49c451bc20..fa6545daef 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -208,7 +208,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] - for (const rel of globSync('packages/*/src/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Events')) continue @@ -248,7 +248,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] - for (const rel of globSync('packages/*/src/index.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Context')) continue diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 6314b39399..ebaf7d5db8 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -4,7 +4,7 @@ * The architectural shape of the harness lives implicitly in each package's * `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror * these as `workspace:^` plus test-only extras, which would add noise). This - * script reads every `packages/* /package.json`, keeps only the + * script reads every `packages/* /* /package.json`, keeps only the * `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a * GitHub-viewable Mermaid graph plus a dependency table. * @@ -34,7 +34,7 @@ interface Pkg { /** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */ function collect(): Pkg[] { const pkgs: Pkg[] = [] - for (const rel of globSync('packages/*/package.json', { cwd: root })) { + for (const rel of globSync('packages/*/*/package.json', { cwd: root })) { const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { name: string peerDependencies?: Record diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index e0bee4393e..df13d87caa 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,31 +1,22 @@ import { execFileSync } from 'node:child_process' +import { readdirSync } from 'node:fs' import { resolve } from 'node:path' -// publint every publishable package (vendor/ is private upstream code and -// examples/ are not packages; both are out of scope). -// TODO(package-inventory): derive this from the deliberate package hierarchy. -const packages = [ - 'packages/llm', - 'packages/session', - 'packages/session-persistence', - 'packages/session-persistence-jsonl', - 'packages/session-persistence-sqlite', - 'packages/system-prompt', - 'packages/tools', - 'packages/agent', - 'packages/agent-loop', - 'packages/bash', - 'packages/llm-deepseek', - 'packages/llm-pi-ai', - 'packages/bash-local', - 'packages/tool-bash', - 'packages/invariants', - 'packages/acp', - 'packages/ui-stdio', - 'packages/llm-replay', -] - +// publint every harness package. Packages live at packages// +// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private +// upstream code and examples/ are not packages, both out of scope. Derived +// from the hierarchy so a new package needs no edit here. const root = resolve(import.meta.dirname, '..') +const packagesRoot = resolve(root, 'packages') + +const packages = readdirSync(packagesRoot, { withFileTypes: true }) + .filter(group => group.isDirectory()) + .flatMap(group => + readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) + .filter(pkg => pkg.isDirectory()) + .map(pkg => `packages/${group.name}/${pkg.name}`), + ) + for (const path of packages) { execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' }) } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5ea2916cd4..2a497289f4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,41 +1,41 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/src/brand.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } ] } diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index be1a80f86a..57bb824b32 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -49,6 +49,7 @@ const PATTERNS = [ 'README.md', 'docs/**/*.md', 'packages/*/*.md', + 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index dbb1e69235..f8acb26d78 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -36,7 +36,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ -const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] /** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 55250db23f..c93383e40f 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -36,7 +36,7 @@ const root = resolve(import.meta.dirname, '..') * added to a doc with NO manifest entry is still discovered here and reported as * an orphan, instead of being silently skipped. */ -const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md'] +const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] /** One manifest entry: a documented type-equiv block and its source symbol. */ interface ManifestEntry { diff --git a/tsconfig.base.json b/tsconfig.base.json index d26ab0d56d..04b65eb3a3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,24 +34,19 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-llm": ["./packages/llm/src"], - "@deepseek-ai/dsh-session": ["./packages/session/src"], - "@deepseek-ai/dsh-session-persistence": ["./packages/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-system-prompt": ["./packages/system-prompt/src"], - "@deepseek-ai/dsh-tools": ["./packages/tools/src"], - "@deepseek-ai/dsh-agent": ["./packages/agent/src"], - "@deepseek-ai/dsh-agent-loop": ["./packages/agent-loop/src"], - "@deepseek-ai/dsh-bash": ["./packages/bash/src"], - "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], - "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], - "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"], - "@deepseek-ai/dsh-acp": ["./packages/acp/src"], - "@deepseek-ai/dsh-ui-stdio": ["./packages/ui-stdio/src"], - "@deepseek-ai/dsh-llm-replay": ["./packages/llm-replay/src"] + // One wildcard maps every @deepseek-ai/dsh- to its source. Package + // dir names are unique across groups, so first-on-disk-wins resolution is + // unambiguous; adding a package under an existing group needs no edit + // here. The build graph's project references (tsconfig.build.json) stay + // explicit — TS project references have no wildcard form. + "@deepseek-ai/dsh-*": [ + "./packages/core/*/src", + "./packages/llm/*/src", + "./packages/bash/*/src", + "./packages/session-persistence/*/src", + "./packages/ui/*/src", + "./packages/support/*/src" + ] } } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 50812b226a..6d353c1796 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,23 +10,23 @@ { "path": "./vendor/timer" }, { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, - { "path": "./packages/llm" }, - { "path": "./packages/session" }, - { "path": "./packages/session-persistence" }, - { "path": "./packages/session-persistence-jsonl" }, - { "path": "./packages/session-persistence-sqlite" }, - { "path": "./packages/system-prompt" }, - { "path": "./packages/agent" }, - { "path": "./packages/tools" }, - { "path": "./packages/agent-loop" }, - { "path": "./packages/bash" }, - { "path": "./packages/llm-deepseek" }, - { "path": "./packages/llm-pi-ai" }, - { "path": "./packages/bash-local" }, - { "path": "./packages/tool-bash" }, - { "path": "./packages/invariants" }, - { "path": "./packages/acp" }, - { "path": "./packages/ui-stdio" }, - { "path": "./packages/llm-replay" } + { "path": "./packages/llm/llm" }, + { "path": "./packages/core/session" }, + { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-persistence-jsonl" }, + { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/core/system-prompt" }, + { "path": "./packages/core/agent" }, + { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-loop" }, + { "path": "./packages/bash/bash" }, + { "path": "./packages/llm/llm-deepseek" }, + { "path": "./packages/llm/llm-pi-ai" }, + { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/support/invariants" }, + { "path": "./packages/ui/acp" }, + { "path": "./packages/support/ui-stdio" }, + { "path": "./packages/support/llm-replay" } ] } diff --git a/tsconfig.test.json b/tsconfig.test.json index 5976d5b43c..a7933dad55 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -6,5 +6,5 @@ "composite": false, "types": ["node"] }, - "include": ["vendor/*/src", "packages/*/src", "packages/*/tests", "examples"] + "include": ["vendor/*/src", "packages/*/*/src", "packages/*/*/tests", "examples"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 77e2326775..a2b2358a09 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -16,25 +16,15 @@ "@cordisjs/plugin-timer": ["./vendor/timer/lib"], "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], - "@deepseek-ai/dsh-llm": ["./packages/llm/src"], - "@deepseek-ai/dsh-session": ["./packages/session/src"], - "@deepseek-ai/dsh-session-persistence": ["./packages/session-persistence/src"], - "@deepseek-ai/dsh-session-persistence-jsonl": ["./packages/session-persistence-jsonl/src"], - "@deepseek-ai/dsh-session-persistence-sqlite": ["./packages/session-persistence-sqlite/src"], - "@deepseek-ai/dsh-system-prompt": ["./packages/system-prompt/src"], - "@deepseek-ai/dsh-tools": ["./packages/tools/src"], - "@deepseek-ai/dsh-agent": ["./packages/agent/src"], - "@deepseek-ai/dsh-agent-loop": ["./packages/agent-loop/src"], - "@deepseek-ai/dsh-bash": ["./packages/bash/src"], - "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"], - "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"], - "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"], - "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"], - "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"], - "@deepseek-ai/dsh-acp": ["./packages/acp/src"], - "@deepseek-ai/dsh-ui-stdio": ["./packages/ui-stdio/src"], - "@deepseek-ai/dsh-llm-replay": ["./packages/llm-replay/src"] + "@deepseek-ai/dsh-*": [ + "./packages/core/*/src", + "./packages/llm/*/src", + "./packages/bash/*/src", + "./packages/session-persistence/*/src", + "./packages/ui/*/src", + "./packages/support/*/src" + ] } }, - "include": ["packages/*/src", "packages/*/tests", "examples", "scripts"] + "include": ["packages/*/*/src", "packages/*/*/tests", "examples", "scripts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 3b9039cc36..6723d514b7 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' /** - * JS bundling for all workspace packages (vendor/* + packages/*). + * JS bundling for all workspace packages (vendor and the packages hierarchy). * Declarations are NOT produced here — `tsc -b tsconfig.build.json` owns * .d.ts output (composite project references); hence `dts: false` and * `clean: false` (lib/ already holds tsc's declarations). @@ -10,9 +10,10 @@ import { defineConfig } from 'tsdown' * (schemastery: dual ESM+CJS; logger-console: extra browser entry). */ export default defineConfig({ - // Explicit globs: `workspace: true` would also discover examples/* (any - // package.json), but only vendor/* and packages/* are pnpm workspaces. - workspace: ['vendor/*', 'packages/*'], + // Explicit globs: `workspace: true` would also discover examples (any + // package.json), but only vendor and the packages hierarchy are pnpm + // workspaces. + workspace: ['vendor/*', 'packages/*/*'], entry: ['src/index.ts'], outDir: 'lib', format: ['esm'], diff --git a/vitest.config.ts b/vitest.config.ts index 0878477fb4..91257ea2bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,14 +19,14 @@ export default defineConfig({ // instead applies the one root map to every importer. plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { - include: ['packages/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], + include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). - include: ['packages/*/src/**/*.ts'], - exclude: ['packages/*/src/types.ts'], + include: ['packages/*/*/src/**/*.ts'], + exclude: ['packages/*/*/src/types.ts'], // 100% or it doesn't merge (AGENTS.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see AGENTS.md. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 9316d660da..f06806d763 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], test: { - include: ['packages/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate. From 034779d761341e0d16ea89452aa4dbdd08725908 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:05:31 +0800 Subject: [PATCH 50/87] docs(rfc): propose extracting example apps into packages Add a proposed architecture RFC to make the examples folder thin: each example becomes mostly an invocation of an app package. A shared dsh-agent-core bundle owns the providerless spine; dsh-stdio-agent and dsh-acp-agent app packages bake in their coupled front-door cluster (UI + logger/hmr policy + agent pre-creation), turning the ACP stdout-purity footgun into a property of the artifact. Leaf cordis.yml shrinks to backends + config; start.ts is dropped in favor of a package bin. Supersedes the providerless-example-base RFC (cross-linked) and indexes the new RFC under Proposed -> Architecture. --- docs/rfc/README.md | 1 + ...2026-06-20-extract-example-app-packages.md | 44 +++++++++++++++++++ .../2026-06-20-providerless-example-base.md | 2 + 3 files changed, 47 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6f295957ab..f3aea7cf2f 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -65,6 +65,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md new file mode 100644 index 0000000000..c175d93e98 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -0,0 +1,44 @@ +# RFC: Extract example apps into packages + +Status: proposed + +## Problem + +An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`logger`/`timer`/`hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. + +The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. + +## Proposal + +Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). + +- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop` (neutral `agents: []`). This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** the loop, as code instead of a YAML include. +- **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. +- **Drop `start.ts`.** Each app package exposes a `bin`; 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 move into that bin, owned by the app. +- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying model / systemPrompt / persistence root. ~4 entries, no infra preamble. +- **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` 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. +- **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-agent-core`. + +`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. + +## Why not keep the wiring in shared YAML includes? + +The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stays copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. + +## Acceptance criteria + +- Each example directory is `cordis.yml` + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. +- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s. +- `pnpm run test`, `pnpm run test:snapshot` (re-recorded), `pnpm run typecheck`, `pnpm run knip`, `pnpm run publint`, and `pnpm run doc-sync` are green; the new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. + +## What we give up + +- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README must carry that teaching weight. +- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. +- **Migration cost** (the implementing PR, not this one): three new packages, three leaf rewrites, the boot glue moved into bins, re-recorded ACP snapshots, and rewritten example READMEs + [examples/AGENTS.md](../../../../examples/AGENTS.md). + +## Related + +- Supersedes [Make the shared example base providerless](2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. +- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. diff --git a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md index 2061248596..fae44635d0 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md @@ -2,6 +2,8 @@ Status: proposed +> Superseded by [Extract example apps into packages](2026-06-20-extract-example-app-packages.md): moving the spine into a `dsh-agent-core` bundle and deleting the `base*.yml` files makes the rename below moot. Kept for the record. + ## Problem The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. From bf3cfe84c3e481808ef678eaa40b95c48cfae4c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:11:02 +0800 Subject: [PATCH 51/87] docs(rfc): fix Codex review findings on branded-IDs RFC - Correct the SessionStore signature: `get(id: string)` is non-optional, not `get(id?: string)` (only create/prepare take an optional id). - Broaden the ACP brand-erosion description and the acceptance criterion beyond `Map`: the session-id surface also includes the `bySession` WeakMap, the `loadingIds` Set, and the exported `streamSessionEventUpdate(sessionId)` signature. - Use bare inline code spans for code paths instead of markdown links, matching the house style of the other architecture RFCs and removing the link/bare-span inconsistency within this file (doc-to-doc cross-links stay markdown links per docs/AGENTS.md). --- .../architecture/2026-06-20-branded-ids.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index ad992f6a91..6f92de519a 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -4,21 +4,21 @@ Status: proposed ## Problem -The harness already brands three identifiers — `CallId` ([`packages/llm/src/brand.ts`](../../../../packages/llm/src/brand.ts)), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness already brands three identifiers — `CallId` (`packages/llm/src/brand.ts`), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare`/`get(id?: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the entire ACP session-id surface (`SessionRecord.sessionId: string`, `sessions = new Map()`, `requireSession(sessionId: string)`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Proposal A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts) (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts); type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) - **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. @@ -42,7 +42,7 @@ export function OwnerToken(id: string): OwnerToken { ## Why a distinct OwnerToken brand (not SessionId) -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions @@ -57,7 +57,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Acceptance criteria - `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end: the executor seam, the `dsh-bash-local` generation site, and the `dsh-tool-bash` model-facing surface all speak the brands; `dsh-bash` gains no dependency on `dsh-session`. -- No `Map` keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) remains; the corresponding public method params take the brand, not `string`. +- No collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — this covers `Map`, `WeakMap` value slots, and `Set` membership (e.g. the ACP `bySession`/`loadingIds`), not just `Map`; the corresponding public method params and exported function signatures (e.g. `streamSessionEventUpdate`) take the brand, not `string`. - Brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`); no `as` casts scattered at call sites. - `pnpm run typecheck` and `pnpm run doc-sync` are green; the change is observably type-only (no snapshot, no e2e behavioral diff). From ca2207e26c5c95263bef8fe2c58a36ee16e947c1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:12:14 +0800 Subject: [PATCH 52/87] Fix doc cross-links for the hierarchy; add package-path + shape gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge brought in the RFC-classification reorg and two new doc gates; rewrite every drifted packages/ cross-link (Markdown link targets, moved-README relative depths, and .ts comment paths) to the grouped paths. Add two doc-sync/hygiene gates so the manual checks this restructure needed become automated: - verify-package-paths.ts: flags a packages/ reference (in Markdown or a .ts comment/string) that does not resolve AND names a real package in a segment — i.e. a stale path to a MOVED package. A path naming a non-existent package (a forward-looking proposal) is left alone, so it applies uniformly across proposed/implemented/rejected. - check-workspace-constraints: assert the packages// depth-2 shape (group dirs carry no package.json; no flat or over-nested packages). Group names stay open; only the shape is fixed. --- AGENTS.md | 4 +- docs/cookbook/adding-a-package.md | 4 +- docs/cookbook/adding-a-tool.md | 4 +- docs/cookbook/adding-an-llm-adapter.md | 2 +- docs/cookbook/extension-cookbook.md | 2 +- docs/core-data-structures/bash.md | 6 +- docs/core-data-structures/core.md | 10 +- docs/core-data-structures/llm-streaming.md | 6 +- docs/core-data-structures/persistence.md | 8 +- docs/core-data-structures/session.md | 4 +- docs/core-data-structures/tools.md | 8 +- docs/development.md | 2 +- .../0001-acp-default-export-drops-inject.md | 6 +- ...6-06-18-acp-terminal-and-tool-rendering.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 8 +- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- .../2026-06-14-acp-agent-client-protocol.md | 8 +- .../feature/2026-06-14-acp-multi-session.md | 2 +- ...rop-unconsumed-llm-adapter-change-event.md | 4 +- ...-drop-unconsumed-llm-assembled-surfaces.md | 12 +- .../2026-06-20-prune-dead-seam-methods.md | 8 +- ...06-20-assembled-assistant-messages-only.md | 2 +- .../2026-06-20-drop-acp-session-load.md | 2 +- ...6-20-fold-session-persistence-interface.md | 2 +- .../2026-06-20-truncate-interrupted-turns.md | 2 +- examples/acp-agent/README.md | 4 +- package.json | 3 +- packages/bash/tool-bash/README.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 4 +- packages/llm/llm/README.md | 2 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence/README.md | 4 +- packages/support/invariants/README.md | 2 +- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/src/index.ts | 4 +- packages/support/ui-stdio/README.md | 2 +- packages/ui/acp/README.md | 12 +- packages/ui/acp/acp-feature-support.md | 6 +- scripts/check-workspace-constraints.ts | 34 +++- scripts/verify-package-paths.ts | 149 ++++++++++++++++++ 42 files changed, 268 insertions(+), 88 deletions(-) create mode 100644 scripts/verify-package-paths.ts diff --git a/AGENTS.md b/AGENTS.md index 1c3bc64e30..4ccc92f626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **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. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. +- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). @@ -180,7 +180,7 @@ Each bullet is a bug class that bit us; the rule prevents the reoccurrence. This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible). -In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. +In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index ed40f242e4..1ac20c1c3a 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -6,7 +6,7 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ``` packages// - package.json # copy from packages/tools, adjust name/description/deps + package.json # copy from packages/core/tools, adjust name/description/deps tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib, # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery # if you use Config, + ../ for each dsh dependency) @@ -25,7 +25,7 @@ package.json invariants (enforced by `pnpm run constraints` / `scripts/check-wor | `tsconfig.typecheck.json` | same entry (this file overrides the map wholesale) | | `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | | `scripts/publint-all.ts` | add `'packages/'` to the array | -| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) | +| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 330a2db17c..73706c84f1 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -1,6 +1,6 @@ # Cookbook: adding a tool -How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/tool-bash` (production-grade, three-package seam). +How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam). ## The minimal shape @@ -50,4 +50,4 @@ Prefer not to build policy into the tool. The seam is the `tools/execute` waterf ## Tests every tool needs -Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. +Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index 19bc4e9e05..aae6b0bb8b 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -1,6 +1,6 @@ # Cookbook: adding an LLM adapter -How to connect a new model provider. Reference implementations: `packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. +How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. ## The shape diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index df40f1384a..1739acd32c 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -57,7 +57,7 @@ export function apply(ctx: Context) { A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.abort()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (`await agent.whenIdle()` after `abort()`), not just request it. -`packages/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. +`packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. ```ts import type { Context } from 'cordis' diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 043140e25e..c601d8cd74 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -1,8 +1,8 @@ # Bash Executor -The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. +The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. -Source: [`packages/bash/src/types.ts`](../../packages/bash/src/types.ts) +Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Request vs. spec: the `resolve()` split @@ -120,4 +120,4 @@ interface BashTaskRead { ## The service -`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/src/index.ts`](../../packages/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). +`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 3743e6e0df..bcac8010f0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -61,7 +61,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. -Source: [`packages/llm/src/brand.ts`](../../packages/llm/src/brand.ts) +Source: [`packages/llm/llm/src/brand.ts`](../../packages/llm/llm/src/brand.ts) ```ts type-equiv type Branded = string & { readonly [BRAND]: B } @@ -73,7 +73,7 @@ The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), A conversation is `Message`s; a message is an array of typed **content blocks**. The block union derives from `ContentBlockMap`. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface ContentBlockMap { @@ -116,7 +116,7 @@ The full union, the adapter contract (usage-before-finish, raw-JSON tool argumen One model call is a fully-assembled `GenerateOptions`; the non-streaming result is `GenerateResult`. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv interface GenerateOptions { @@ -180,7 +180,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`: -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv type SessionEvent = { @@ -201,7 +201,7 @@ The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. -Source: [`packages/agent/src/types.ts`](../../packages/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv interface Agent { diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 2e3d4a0838..1019e84a16 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -1,8 +1,8 @@ # LLM Streaming -The wire-level streaming vocabulary of [dsh-llm](../../packages/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. +The wire-level streaming vocabulary of [dsh-llm](../../packages/llm/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. -Source: [`packages/llm/src/types.ts`](../../packages/llm/src/types.ts) +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ## `StreamChunk` — the raw protocol @@ -45,7 +45,7 @@ interface TokenUsage { ## `BlockAssembler` -`BlockAssembler` ([`packages/llm/src/assembler.ts`](../../packages/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s and a final `Message`. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this. +`BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s and a final `Message`. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this. ## The seam diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 797299b1cb..630d38480f 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -16,7 +16,7 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no Per-session metadata travels **separately** from the event log: format version, cwd, and lineage are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv interface SessionHeader { @@ -57,7 +57,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi Both implement the same abstract `SessionPersistence` (create/append/load/list/has/delete over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. +- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6856daeabc..d60b738b6a 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -1,8 +1,8 @@ # Sessions -The in-memory, event-sourced model of [dsh-session](../../packages/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). +The in-memory, event-sourced model of [dsh-session](../../packages/core/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). -Source: [`packages/session/src/types.ts`](../../packages/session/src/types.ts) +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ## `SessionEventMap` — the event vocabulary diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 3fdc70c1d5..f255fdbc32 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -1,8 +1,8 @@ # Tools -The tool pipeline of [dsh-tools](../../packages/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. -Source: [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts) · [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ## `ToolDefinition` — a registered tool @@ -36,7 +36,7 @@ interface ToolDefinition extends ToolSchema { Plugin authors write per-property specs with a boolean `required: true`, and a type-level helper maps the spec to the `execute` argument type — zero casts. The DSL is *machinery that types* `ToolDefinition`; it is intentionally a sub-page detail, not core. -Source: [`packages/tools/src/schema.ts`](../../packages/tools/src/schema.ts) +Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv interface SchemaProp { @@ -109,4 +109,4 @@ How a tool wants its call shown in a UI (an editor tool-call card, a CLI log lin > These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative. -The full presentation field docs live in [`packages/tools/src/index.ts`](../../packages/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). +The full presentation field docs live in [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). diff --git a/docs/development.md b/docs/development.md index 3199893c3d..c2fb100177 100644 --- a/docs/development.md +++ b/docs/development.md @@ -135,7 +135,7 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json -{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/session/src/types.ts" } +{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` `pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 0f72fa2d44..12eee7a2b7 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -24,7 +24,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: +`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' @@ -97,8 +97,8 @@ Both bugs share one root process gap: **no test exercised the plugin through its ## Guardrails added -- **Removed `export default apply`** (`packages/acp/src/index.ts`) — the Bug #1 fix. -- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence')`** (`packages/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap. +- **Removed `export default apply`** (`packages/ui/acp/src/index.ts`) — the Bug #1 fix. +- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence')`** (`packages/core/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap. - **No-key `session/new` e2e over real stdio** (`examples/acp-agent/tests/acp.e2e.ts`): boots the example as a subprocess through the real Loader and asserts `session/new` resolves. This fails loudly on Bug #1 with no API key. Verified it fails when `export default apply` is restored. - **`TSX_TSCONFIG_PATH` in the e2e spawn**: the subprocess runs from a temp cwd, where tsx cannot find the repo-root tsconfig `paths` map by searching upward — so dsh-* imports silently fell back to built `lib/`. Pointing tsx at the repo tsconfig makes resolution cwd-independent and ensures the test runs *source*, not a possibly-stale build. - **AGENTS.md defensive pattern**: "Line coverage is not behavior coverage; test the REAL entry path, not a synthetic stand-in" — codifies the lesson for every future plugin. diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index eefc8b09ad..6e108dc4c1 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. +The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../proposed/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.) 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 a0f6497c2a..af8c40c55e 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 @@ -18,13 +18,13 @@ A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it ov ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/session/src/types.ts](../../../../packages/session/src/types.ts): "raw chunks are the replay record"). +The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. ### Replay derives the model script from the log -The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. +The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. ### The in-memory replay entry honors the full LLM contract @@ -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. -`examples/base.yml` always loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm-deepseek/src/index.ts](../../../../packages/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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. +`examples/base.yml` always 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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. ### Two goldens: normalize, then snapshot @@ -66,7 +66,7 @@ Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the exe ### The replay plugin is its own package -The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. +The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/support/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. ### Two subcommands, replay in the default gate diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index b6fc29ee90..2001147be8 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -51,7 +51,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm-deepseek/src/index.ts](../../../../packages/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value, not its length. (An earlier draft echoed `${#KEY}`; dropped as needless metadata.) ### Scope, runtime shape diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 7056c74977..f133cc1d82 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. +> **Implementation status (MVP landed):** steps 1, 2, 3, 4, 6, 7, 8 are implemented in `packages/ui/acp` + `examples/acp-agent`. **Step 5 (the `session/request_permission` permission gate) is deferred** — the bridge ships a pass-through (tools run with the executor's full authority) marked `TODO(rfc010-permission-gate)`, and lays down only the `WeakMap` ownership seam the gate will build on. Status stays `proposed` until the gate lands. `session/cancel` is the queue-aware `agent.cancel()`: it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. **Per-session `cwd` is now honored** (lifting the original "launch the server in the workspace root" restriction — see § Deferred): `session/new` accepts any absolute `cwd`, and `session/load` requires the request `cwd` to match the persisted session `cwd` so the editor and bash executor agree on the workspace. ## Problem @@ -17,7 +17,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/execute` waterfall. -It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. +It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. The mapping between ACP and existing harness seams — each row names the seam and any required extension: @@ -43,9 +43,9 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom ## Plan -1. Package scaffold `packages/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) +1. Package scaffold `packages/ui/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps. -3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. +3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/core/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. 4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install listeners before `send()`; gate on an observed `agent/turn-start` (confirms work was accepted) then resolve on the next `agent/turn-end`; reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. 6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md index faa8a48f65..ed51a2ded8 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md @@ -3,7 +3,7 @@ Status: proposed -> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/acp` + `packages/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. +> **Implementation status:** the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in `packages/ui/acp` + `packages/bash/tool-bash`. **Per-session *permission* ownership is deferred** — it depends on [the ACP support permission gate](2026-06-14-acp-agent-client-protocol.md) (`TODO(rfc010-permission-gate)`), which is itself deferred; the `agent→sessionId` reverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see [agent lifecycle & ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)): the factory returns a per-agent `AgentHandle` whose `dispose()` stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status stays `proposed` until per-session permission ownership lands. > **Target-client note:** Zed is the current target ACP client, and its ACP client maintains a `HashMap` plus `pending_sessions` for concurrent `session/load` calls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See [the rejected simplification](../../rejected/simplification/2026-06-20-single-session-acp-bridge.md). diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index be39827bf0..bf5eb3bed2 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. +`LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. This differs from `tools/change` and `system-prompt/change`. Those two events are also unconsumed today, but they are plausible registry-change signals for future live tool/prompt UIs. LLM adapter registration is more of a boot-time implementation detail: adapters are not a user-visible palette and the real model-call interception seam is `llm/stream`. Keeping an adapter-change event with no listener repeats the [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern at a smaller scale. @@ -19,7 +19,7 @@ Remove only `llm/adapter-change`: - Simplify `registerAdapter()`'s effect generator: keep the mutation and rollback disposer for HMR/disposal, but drop the listener-throw rollback ordering that exists only for the removed event. - Remove the "Emits `llm/adapter-change` on registration and disposal" sentence from `LlmService.registerAdapter`'s JSDoc. - Rewrite the adapter-disposer test to assert the returned disposer removes the adapter without subscribing to `llm/adapter-change`; delete the listener-throw rollback test that exists solely for the removed event. -- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/README.md](../../../../packages/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. +- Update the event taxonomy table in [docs/architecture.md](../../../architecture.md) and [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md). The [doc-sync-enforcement RFC](../../implemented/process/2026-06-11-doc-sync-enforcement.md) should avoid using `llm/adapter-change` as an example once the event is gone. ## Why not remove every registry change event? diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 1f0a1efadd..cf93b3e83a 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -4,17 +4,17 @@ Status: proposed ## Problem -`LlmService` ([packages/llm/src/index.ts](../../../../packages/llm/src/index.ts)) exposes three call surfaces over a model: +`LlmService` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) exposes three call surfaces over a model: - `stream()` — raw `StreamChunk`s, dispatched through the `llm/stream` waterfall. -- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/src/index.ts)). -- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/src/index.ts)). +- `streamBlocks()` — a "convenience view" that runs the chunks through a `BlockAssembler` and yields completed `ContentBlock`s in stream order ([index.ts:137-144](../../../../packages/llm/llm/src/index.ts)). +- `generate()` — one fully-assembled `GenerateResult`, dispatched through a second `llm/generate` waterfall ([index.ts:151-157](../../../../packages/llm/llm/src/index.ts)). -The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/agent-loop/src/loop.ts](../../../../packages/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. +The only production consumer of the LLM service is the agent loop, and it uses `stream()` exclusively — feeding raw chunks through its own `BlockAssembler` so it can log chunks for replay fidelity while assembling in parallel ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts), the `ctx.llm.stream(req)` step). Grepping `streamBlocks` and `ctx.llm.generate` across `packages/*/src` and `examples/*/src` finds no production callers. The references are the service methods, docs, and tests; adapter tests use `generate()` as a convenient driver, but they can hand-drain `stream()` through the same assembler helper without preserving a public production API. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: assembled-view APIs with tested contracts, consumed by tests rather than production. They were built speculatively for consumers that do not care about token-level deltas, but the one real consumer cares about deltas precisely so it can persist high-fidelity replay data. -`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/src/assembler.ts:138-168](../../../../packages/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. +`streamBlocks()` drags a dedicated slice of `BlockAssembler` behind it: `flushReady()` and `flushRemaining()` ([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts)) plus the `flushed` cursor field exist only to support incremental in-order yield. `generate()` drags `GenerateResult`, `BlockAssembler.result()`, and the `llm/generate` waterfall as a second interception surface over the same underlying stream. The loop's assembler usage is `push()` / `message()` / `usage` / `finish` — not streaming flush or one-shot service assembly. ## Proposal @@ -34,7 +34,7 @@ Make `stream()` the only public LLM call surface: - `pnpm run test:coverage` stays at 100% per-file (the deleted methods take their dedicated tests with them; no remaining line goes uncovered). - Adapter tests still exercise both real adapters through `stream()` and the shared assembler, not through a test-only public shortcut. - The loop behaves identically — verified by unchanged ACP snapshot goldens. -- `packages/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. +- `packages/llm/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md index de79c3f355..117fe9c72b 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md @@ -8,13 +8,13 @@ Two capability seams ([interface / implementation / consumer](../../implemented/ ### `SessionPersistence.has()` and `.delete()` -The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/agent-loop/src/index.ts:176-194](../../../../packages/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/acp/src/index.ts](../../../../packages/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. +The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176-194](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. -`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. +`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. ### `BashExecutor.get()` and `.list()` -The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/src/index.ts:88-107](../../../../packages/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash-local/src/index.ts:179-191](../../../../packages/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/tests/service.spec.ts](../../../../packages/bash/tests/service.spec.ts), [packages/bash-local/tests/executor.spec.ts](../../../../packages/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/tool-bash/tests/tools.spec.ts](../../../../packages/tool-bash/tests/tools.spec.ts), [packages/tool-bash/tests/integration.spec.ts](../../../../packages/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. +The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/bash/src/index.ts:88-107](../../../../packages/bash/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash/bash-local/src/index.ts:179-191](../../../../packages/bash/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/bash/tests/service.spec.ts](../../../../packages/bash/bash/tests/service.spec.ts), [packages/bash/bash-local/tests/executor.spec.ts](../../../../packages/bash/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/bash/tool-bash/tests/tools.spec.ts](../../../../packages/bash/tool-bash/tests/tools.spec.ts), [packages/bash/tool-bash/tests/integration.spec.ts](../../../../packages/bash/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. ## Proposal @@ -22,7 +22,7 @@ Remove the methods nothing consumes, from the abstract seam, the implementation, - `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. - `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. -- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/README.md](../../../../packages/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/README.md](../../../../packages/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence-sqlite/README.md](../../../../packages/session-persistence-sqlite/README.md), [packages/session-persistence-jsonl/README.md](../../../../packages/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/src/index.ts](../../../../packages/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. +- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/session-persistence/README.md](../../../../packages/session-persistence/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/bash/README.md](../../../../packages/bash/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence/session-persistence-sqlite/README.md](../../../../packages/session-persistence/session-persistence-sqlite/README.md), [packages/session-persistence/session-persistence-jsonl/README.md](../../../../packages/session-persistence/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/session-persistence/src/index.ts](../../../../packages/session-persistence/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. ## Why not keep them as "the seam should be complete"? diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md index 73dd601139..56f20c04ef 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md @@ -17,7 +17,7 @@ ACP `session/load` can replay prior assistant messages as complete content block ## Acceptance criteria - `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed. -- [Session persistence docs](../../../../packages/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. +- [Session persistence docs](../../../../packages/session-persistence/session-persistence/README.md) no longer require every stream chunk to be stored verbatim. - `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks. - `session/load` renders completed assistant messages from `assistant/message`. - Stored logs get much smaller and remain `seq`-contiguous without chunk holes. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md index c8d86066bf..f6cc3a3236 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md @@ -18,7 +18,7 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f - `initialize` does not advertise load support. - The `session/load` handler, loading-id tracking, cwd preflight for loaded sessions, and load replay tests are removed. - Snapshot fixtures no longer rely on load replay presentation. -- [ACP docs](../../../../packages/acp/README.md) describe fresh-session support only. +- [ACP docs](../../../../packages/ui/acp/README.md) describe fresh-session support only. ## What we give up diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index 6731b50211..229e2810fc 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -20,7 +20,7 @@ The implementing PR should update the [capability seams](../../implemented/archi - `dsh-session` exports the persistence service type, coordinator, and contract helpers. - JSONL and SQLite backend packages depend on `dsh-session` directly. - `agent-loop` resume uses the session-owned service key. -- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session-persistence/README.md) explain why backend implementations remain separate. +- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md), [shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), and [package docs](../../../../packages/session-persistence/session-persistence/README.md) explain why backend implementations remain separate. ## What we give up diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index ed8e41d598..90e2b3f7a4 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -19,7 +19,7 @@ This makes the persisted turn boundary simple: a completed `turn/end` is the che - `TurnEndReasonMap` drops the `interrupted` variant. - `interruptedTurnClosers()` and its tests disappear. - The persistence coordinator's repair hook truncates backend-specific torn/open tail state without appending closers. -- [Session persistence docs](../../../../packages/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. +- [Session persistence docs](../../../../packages/session-persistence/session-persistence/README.md) say load returns the last completed turn, plus no partial final turn. - Snapshot and contract tests update together with the behavior they pin. - The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy, with no migration path. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index f6cab28700..afaf920bfe 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -28,7 +28,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session. +The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session. ## Snapshot tests (record-once / replay-deterministic) @@ -36,4 +36,4 @@ This example is the home of the harness's **snapshot tests** — they boot this ## MVP limitations -The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/acp/README.md` for the full contract. +The bridge supports N concurrent sessions per connection, each in its own workspace `cwd` (RFC 011). Remaining limits: prompts support ACP's baseline `text` and `resource_link` blocks only, `additionalDirectories` and `mcpServers` are rejected, and the tool-permission gate is deferred (`TODO(rfc010-permission-gate)` — tools run with the executor's full authority). See `packages/ui/acp/README.md` for the full contract. diff --git a/package.json b/package.json index 047c00eac2..499cffaf77 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", @@ -34,7 +35,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 0a2d3dd4f4..656a22cdb1 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,7 +34,7 @@ The owning agent's session token (`session.header.id`) is stamped onto the task ## UI presentation -These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation"). +These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/ui/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation"). ## Background completion notices diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c51c9132b6..c7ea092c72 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -13,7 +13,7 @@ This is the only package in the harness that contains concrete loop logic. Every `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): - `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown. diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 39fba37fd0..df4163670d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -18,7 +18,7 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle. @@ -55,7 +55,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 02758d404c..4326c5a1f8 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -43,4 +43,4 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Real adapters -Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). +Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 92899de4b5..28a64c4c11 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). +- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 74e79ac8f3..23916f2bfe 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence-sqlite -A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. > **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 42fb137287..b21a01b763 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-persistence -The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. +The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. @@ -39,7 +39,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index a3901a0f5d..a08ccf01a7 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -44,7 +44,7 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`). ## Why runtime, not deep-readonly types -A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Seeded sessions diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index a914ab23b6..91230cc113 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -33,4 +33,4 @@ Two failure modes are not reconstructable from `assistant/chunk` alone — a pur ## 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)). +Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index a1c5f4f1d1..67fcf09697 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -10,7 +10,7 @@ * The fixture IS the persisted session log (`/session.jsonl`): its * `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by * `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model - * call per loop step — see packages/agent-loop/src/loop.ts). Recording is + * call per loop step — see packages/core/agent-loop/src/loop.ts). Recording is * therefore "run the real agent once and harvest the `.jsonl`", done by the * snapshot harness — this plugin does not record. * @@ -46,7 +46,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' * so it can faithfully replay BOTH branches of the documented LLM failure * contract — an adapter may THROW from `stream()` or end with a `finish` error * chunk — plus a `hang` marker for cancellation scenarios (mirrors the - * `MockAdapter` `hang` support in packages/agent-loop/tests). + * `MockAdapter` `hang` support in packages/core/agent-loop/tests). * * A `throw` entry carries any `chunks` the adapter emitted BEFORE it threw, so * a mid-stream transport failure (partial output then `STREAM_CLOSED`) replays diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index e49b0240b9..b65fd4d8e1 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -41,4 +41,4 @@ Disposal (HMR or fiber teardown) closes the readline interface, which also fires ## 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. +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/ui/acp/README.md b/packages/ui/acp/README.md index b0334a0a2a..ad50383542 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. -It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin @@ -53,7 +53,7 @@ A tool whose call IS a shell command (`bash`) can render as a real **terminal ca - `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card. - `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). ## Settle-exactly-once @@ -61,16 +61,16 @@ A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical s ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). ## Known limitations (tracked TODOs) -- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. +- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land. - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. ## stdout is the protocol -The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. +The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. ## Running diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index d5dd379bf5..6b171769d3 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -92,7 +92,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs ## 5. Tool-call rendering -Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| @@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them | Feature | Stable | Bridge | Notes | |---|---|---|---| | `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. | -| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). | +| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). | | Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. | | `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. | | Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. | @@ -159,4 +159,4 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra - Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo. - Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp). -- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../docs/rfc/README.md). +- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../../docs/rfc/README.md). diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 3ec4920ad9..eae8dd55f1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -5,7 +5,7 @@ * Run: `tsx scripts/check-workspace-constraints.ts`. */ -import { readdirSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') @@ -105,7 +105,37 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { return errors.map(error => `${relative(root, join(root, dir, 'package.json'))}: ${error}`) } -const errors = workspaceManifests().flatMap(checkWorkspace) +/** + * Enforce the packages/ hierarchy SHAPE: every package lives at exactly + * `packages//`. A group dir is a pure container — it holds packages, + * never sources of its own — so it must NOT carry a package.json, and a package + * must NOT sit directly at the `packages/` root (the old flat layout) nor nest a + * level deeper. The group NAMES are open on purpose: a new group may be added + * without touching this gate, but the depth-2 shape is fixed. This is what keeps + * a stray flat package or an over-nested one from regressing the hierarchy. + */ +function checkHierarchyShape(): string[] { + const errors: string[] = [] + const packagesRoot = join(root, 'packages') + for (const group of readdirSync(packagesRoot, { withFileTypes: true })) { + if (!group.isDirectory()) continue + const groupRel = join('packages', group.name) + if (existsSync(join(packagesRoot, group.name, 'package.json'))) { + errors.push(`${groupRel}: a group dir must not contain a package.json — packages live at packages//, not directly under packages/`) + continue + } + for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) { + if (!pkg.isDirectory()) continue + const pkgRel = join(groupRel, pkg.name) + if (!existsSync(join(packagesRoot, group.name, pkg.name, 'package.json'))) { + errors.push(`${pkgRel}: expected a package here (no package.json found) — the hierarchy is exactly packages//, no deeper nesting`) + } + } + } + return errors +} + +const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()] if (errors.length > 0) { console.error(errors.join('\n')) process.exitCode = 1 diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts new file mode 100644 index 0000000000..368a607a1d --- /dev/null +++ b/scripts/verify-package-paths.ts @@ -0,0 +1,149 @@ +/** + * Doc-sync gate: catch DRIFTED `packages/` references — a path to a + * package that has MOVED, written as prose in Markdown or in a TypeScript + * comment/string. Docs and comments cite package locations by root-relative + * path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`); + * `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs` + * only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick + * prose or a code comment goes unchecked. The package-hierarchy reorg is the + * motivating case: it moved every package under a `{group}/` folder, so a stale + * `packages/tools` (now `packages/core/tools`) reads fine to a human but points + * at nothing. + * + * The check is drift-scoped, NOT a blanket existence test: a broken + * `packages/` token is a violation ONLY when one of its path segments is + * the directory name of a package that actually exists on disk — i.e. the + * package is real and the path is merely stale. A token naming a package that + * exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an + * illustrative `packages//` skeleton) is left alone: this gate reports + * MOVED paths, not hypothetical or future ones, so it applies uniformly to + * proposed/implemented/rejected docs without per-lifecycle exclusions. This is + * checker, not fixer: it reports and never rewrites. + * + * Detection is a token scan, NOT an AST walk: package refs live in free prose, + * backticks, and comments. We match `packages/` tokens whose path is made + * of plain path characters, so a glob, a ``, or a `{brace,expansion}` + * terminates the match before those chars and is never probed. + * + * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown + * across README/docs/packages/AGENTS, and `.ts` under packages/** and + * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source). + * + * Run: `tsx scripts/verify-package-paths.ts`. + */ + +import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { glob } from 'node:fs/promises' + +const root = resolve(import.meta.dirname, '..') + +/** Markdown + repo-authored TypeScript that may cite package paths. */ +const PATTERNS = [ + 'README.md', + 'docs/**/*.md', + 'packages/*/*.md', + 'packages/*/*/*.md', + 'AGENTS.md', + 'packages/AGENTS.md', + 'packages/**/*.ts', + 'examples/**/*.ts', +] + +/** Paths excluded from the scan: built output and vendored upstream source. */ +const isExcluded = (p: string): boolean => + p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/') + +/** + * Directory names of every real package, `packages//`. A broken + * reference is only flagged when one of its segments is in this set — that is + * what scopes the gate to DRIFT (a moved real package) rather than typos or + * not-yet-existing packages named in a proposal. + */ +function realPackageNames(): Set { + const names = new Set() + const pkgRoot = resolve(root, 'packages') + for (const group of readdirSync(pkgRoot, { withFileTypes: true })) { + if (!group.isDirectory()) continue + for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) { + if (pkg.isDirectory()) names.add(pkg.name) + } + } + return names +} + +const packageNames = realPackageNames() + +/** + * Match a `packages/` reference token. The character class is plain path + * characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion + * (`{`, `}`, `,`) terminates the match before those chars and is never probed — + * those are patterns, not real paths. A trailing `.`/`/` (e.g. a sentence-ending + * period) is trimmed before the existence check. + */ +const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g + +/** A broken package reference: a stale root-relative `packages/…` path. */ +interface Violation { + file: string + /** 1-based line where the reference appears. */ + line: number + ref: string +} + +/** + * Find every DRIFTED `packages/…` reference in one file: a token that does not + * resolve on disk AND names a real package in one of its segments (so it is a + * moved path, not a typo or a not-yet-existing package). The same real-package + * test also screens out a bare `packages` (no segment) and illustrative + * skeletons whose segment is not a package. + */ +function findViolations(absPath: string): Violation[] { + const file = relative(root, absPath) + const source = readFileSync(absPath, 'utf8') + const out: Violation[] = [] + const lines = source.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line === undefined) continue + for (const m of line.matchAll(PKG_REF)) { + // Trim a trailing path separator or sentence punctuation that the greedy + // class may have swallowed (`packages/core/tools.` / `…/tools/`). + const ref = m[0].replace(/[./]+$/, '') + if (existsSync(resolve(root, ref))) continue + // Only a stale path to a REAL (moved) package is a violation; a segment + // matching a live package name is the drift signal. + const segments = ref.split('/').slice(1) + if (segments.some(seg => packageNames.has(seg))) { + out.push({ file, line: i + 1, ref }) + } + } + } + return out +} + +const all: Violation[] = [] +let checked = 0 +const seen = new Set() +for (const pattern of PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) { + if (isExcluded(match)) continue + // Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md. + const real = realpathSync(resolve(root, match)) + if (seen.has(real)) continue + seen.add(real) + checked++ + all.push(...findViolations(real)) + } +} + +if (all.length === 0) { + console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`) + process.exit(0) +} + +console.error('verify-package-paths: broken packages/* references found (target does not exist):') +for (const v of all) { + console.error(` ${v.file}:${v.line} ${v.ref}`) +} +process.exit(1) From d9c9c5d403f2f4c4d3657e83e659291f3e82df0d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:25:11 +0800 Subject: [PATCH 53/87] docs(rfc): sharpen agent-loop, timer, and leaf-config accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review against the actual source surfaced three imprecisions: - agent-loop placement: the core bundle forwards agent-loop's `agents` list as its own config (default []), matching AgentLoop.Config, rather than hardcoding []. This is what lets a shared core coexist with stdio pre-creating `main` and acp pre-creating none — and it directly rebuts the reason base-core.yml gives today for keeping the loop out of core. - timer is universal and stdout-safe, so it lives in the shared spine, not the per-app front-door cluster (only logger + hmr are app-specific). - model/systemPrompt land in different places per app (stdio onto the pre-created agent, acp onto the bridge plugin), routed by the app package's own Config — not a single uniform bundle entry. --- .../2026-06-20-extract-example-app-packages.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index c175d93e98..e129acf64e 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -4,18 +4,18 @@ Status: proposed ## Problem -An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`logger`/`timer`/`hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. +An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. -The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. +The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. ## Proposal Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop` (neutral `agents: []`). This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** the loop, as code instead of a YAML include. +- **`@deepseek-ai/dsh-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`. This is today's [base-core.yml](../../../../examples/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 (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/agent-loop/src/index.ts](../../../../packages/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today 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. - **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. - **Drop `start.ts`.** Each app package exposes a `bin`; 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 move into that bin, owned by the app. -- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying model / systemPrompt / persistence root. ~4 entries, no infra preamble. +- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle 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). A handful of entries, no infra preamble. - **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` 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. - **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-agent-core`. From 5024cd57576019fe39b216bc5ea907230c955907 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:25:33 +0800 Subject: [PATCH 54/87] Document the package hierarchy and finalize the RFC Add a README to each group dir (core/llm/bash/session-persistence/ui/ support) stating its role and product-vs-support classification, and rewrite packages/README.md around the hierarchy (group table, grouped "what goes where", removed the package-hierarchy FIXME). Move the package-hierarchy RFC to implemented/architecture/ and rewrite it to describe what shipped (placement rationale, the paths-wildcard and publint dedup, the two new guardrail gates). Fold the remaining tsconfig.build.json references dedup into the discover-package-inventory proposal and fix its cross-link. Update AGENTS.md: regrouped repo-layout map, depth-2 globs, the new verify-package-paths gate in the doc-sync listing, and a note that we lean toward stricter lint in the agentic-coding era (machine-caught errors and a consistent foundation outweigh the one-time cost). --- AGENTS.md | 68 ++++++++++++------- docs/rfc/README.md | 2 +- .../2026-06-20-package-hierarchy.md | 67 ++++++++++++++++++ .../2026-06-20-package-hierarchy.md | 59 ---------------- .../2026-06-20-discover-package-inventory.md | 10 +-- packages/AGENTS.md | 4 +- packages/README.md | 60 +++++++++------- packages/bash/README.md | 11 +++ packages/core/README.md | 13 ++++ packages/llm/README.md | 11 +++ packages/session-persistence/README.md | 11 +++ packages/support/README.md | 11 +++ packages/ui/README.md | 9 +++ 13 files changed, 219 insertions(+), 117 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md delete mode 100644 docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md create mode 100644 packages/bash/README.md create mode 100644 packages/core/README.md create mode 100644 packages/llm/README.md create mode 100644 packages/session-persistence/README.md create mode 100644 packages/support/README.md create mode 100644 packages/ui/README.md diff --git a/AGENTS.md b/AGENTS.md index 4ccc92f626..fb1750b423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,25 +34,37 @@ vendor/ Vendored Cordis framework source (original npm names, private). See vendor/README.md for the manifest, local-modification log, and the upstream sync procedure. Do NOT edit casually — every divergence must be logged there. -packages/ Harness packages, all named @deepseek-ai/dsh-: - llm/ abstract LLM service + content-block vocabulary - llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) - llm-pi-ai/ DeepSeek adapter via @earendil-works/pi-ai (design twin) - session/ event-sourced session log + in-memory store - system-prompt/ prompt-section + tool-schema assembly registry - tools/ tool registry + tools/execute waterfall - agent/ Agent interface, registry, agent/* event vocabulary - agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver - invariants/ dev-mode event-contract invariants + session-log freeze - bash/ abstract bash executor seam (ctx.bash) — interface only - bash-local/ local-subprocess BashExecutor implementation - tool-bash/ model-facing bash/bash_output/bash_kill tool schemas - acp/ Agent Client Protocol bridge: drive the agent from an ACP - editor (Zed) over JSON-RPC stdio - 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) +packages/ Harness packages, grouped by role at packages///. + Every package is named @deepseek-ai/dsh-; the group dir is a + pure container (no package.json). See packages/README.md and each + group's README.md for the product-vs-support split. + core/ product API spine + session/ event-sourced session log + in-memory store + system-prompt/ prompt-section + tool-schema assembly registry + tools/ tool registry + tools/execute waterfall + agent/ Agent interface, registry, agent/* event vocabulary + agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver + llm/ LLM capability family + llm/ abstract LLM service + content-block vocabulary + llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) + llm-pi-ai/ DeepSeek adapter via @earendil-works/pi-ai (design twin) + bash/ bash capability family + bash/ abstract bash executor seam (ctx.bash) — interface only + bash-local/ local-subprocess BashExecutor implementation + tool-bash/ model-facing bash/bash_output/bash_kill tool schemas + session-persistence/ persistence capability family + session-persistence/ durable persistence seam + write coordinator + session-persistence-jsonl/ JSONL-sidecar backend + session-persistence-sqlite/ SQLite backend + ui/ product integration surfaces + acp/ Agent Client Protocol bridge: drive the agent from an ACP + editor (Zed) over JSON-RPC stdio + 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) examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools @@ -83,7 +95,7 @@ scripts/ repo maintenance scripts (vendor-manifest guard, publint runner). ```sh pnpm install # pnpm workspaces, node >= 24 pnpm run test # vitest run (packages|examples/*/tests/**/*.spec.ts) -pnpm run test:coverage # vitest run --coverage (per-file 100% gate on packages/*/src) +pnpm run test:coverage # vitest run --coverage (per-file 100% gate on packages/*/*/src) pnpm run test:e2e # real-API tests (packages|examples/*/tests/**/*.e2e.ts); # self-skips without DEEPSEEK_API_KEY — see Secrets below pnpm run test:snapshot # ACP snapshot tests (examples/*/tests/**/*.snapshot.ts): @@ -102,10 +114,10 @@ pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run build # tsc -b tsconfig.build.json && tsdown (JS bundles into lib/) pnpm run knip # dead-code / unused-dependency check -pnpm run publint # package.json publish-correctness check (publishable packages/*) +pnpm run publint # package.json publish-correctness check (every packages/*/* package) pnpm run hygiene # knip + publint + workspace constraints pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, - # packages/*/*.md (doc/code drift gate) + # packages/*/*.md + packages/*/*/*.md (doc/code drift gate) pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md # (events + services) from the interface Events / Context source pnpm run verify-cordis-catalog # assert that generated catalog is not stale @@ -113,10 +125,12 @@ pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) pnpm run verify-doc-refs # assert every docs/*.md path cited in a packages|examples # 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 verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-rfc-classification + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -158,7 +172,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **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. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. +- **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). @@ -178,11 +192,13 @@ Each bullet is a bug class that bit us; the rule prevents the reoccurrence. ## Type Safety and Documentation -This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible). +This codebase aims to be **very type-safe and well documented** for maintainability. Code that fails to compile under `strict: true` (with `noImplicitAny` enabled for all `packages/*/*` source) is not acceptable. Every `any` that remains must have a specific justification (a comment explaining why a narrower type is infeasible). + +**Almost always lean toward the stricter lint rule.** In the agentic-coding era the cost/benefit of strictness has inverted: a machine writes and reads most of the code, so the one-time cost of satisfying a stricter rule is cheap and paid by a tool, while the benefit — a whole class of error caught mechanically, a consistent foundation every agent can rely on, less reviewer attention spent on what a linter could have caught — compounds across every future change. When choosing whether to enable a rule, tighten an existing one, or add a new gate (a `verify-*` script, a constraint check), default to YES unless it has a concrete, recurring false-positive problem. Prefer a narrowly-scoped escape hatch (a justified inline disable with a reason, a per-path override) over leaving the rule off globally. The same reasoning motivates this repo's many bespoke gates (`doc-sync`, `verify-package-paths`, the workspace-shape constraint): encode the invariant in a check so no human or agent has to remember it. In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6f295957ab..8b726e3579 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -64,7 +64,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | -| [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | ### Process @@ -115,6 +114,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | | [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 | +| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md new file mode 100644 index 0000000000..0b9ff5cbfb --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -0,0 +1,67 @@ +# RFC: Reorganize packages into a modular hierarchy + +Status: implemented + +## Problem + +`packages/` was flat: 18 packages all sat at `packages//`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational. + +This was not just cosmetic. Because every top-level package looked like part of the same public surface, future removal was harder, and publish/lint/doc scripts had to encode intent through comments or hand-maintained static lists rather than reading it off the layout. + +## What landed + +Packages are grouped by modular role at a uniform `packages///` depth. Group directories are pure containers (no `package.json`); every package keeps its `@deepseek-ai/dsh-` name — this is repo structure and maintenance policy, not package renaming. + +```text +packages/ + core/ (product API spine) + session/ + system-prompt/ + tools/ + agent/ + agent-loop/ + llm/ (product — capability family) + llm/ + llm-deepseek/ + llm-pi-ai/ + bash/ (product — capability family) + bash/ + bash-local/ + tool-bash/ + session-persistence/ (product — capability family) + session-persistence/ + session-persistence-jsonl/ + session-persistence-sqlite/ + ui/ (product integration) + acp/ + support/ (dev/test/example infrastructure) + invariants/ + ui-stdio/ + llm-replay/ +``` + +### Placement decisions + +- **Same-name nesting for capability families.** A family's interface package sits at `packages///` (`llm/llm`, `bash/bash`, `session-persistence/session-persistence`), with implementations and consumers as flat siblings. There is no extra `adapters/`/`impls/` sub-tier — every package is exactly depth 2, which keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package (unique dir names make first-on-disk-wins unambiguous). +- **`session` stays in `core/`; persistence is its own family.** The session log is core product API. Its storage backends form a parallel capability family (`session-persistence/`) mirroring `llm/` and `bash/`, rather than nesting under `core/session/`. +- **`agent-loop` is in `core/`.** It is the one concrete implementation of the `agent` seam, but it ships as the harness's default product loop, so it lives with the core spine. Plugins still depend on the `agent` vocabulary, never on `agent-loop`, so the loop stays swappable. +- **`invariants` and `ui-stdio` are `support/`, not product.** `invariants` is dev-mode contract checking. `ui-stdio` was extracted from the examples for reuse and the coverage gate — it is example-coupled, so it sits in `support/` alongside `llm-replay` (the snapshot-test replay adapter). `acp` is the only `ui/` member because it is a real product surface (the ACP bridge an editor drives), structurally distinct from the readline demo helper. + +### Deduplicating the package lists + +The package list had been enumerated in five places. The uniform depth-2 layout collapsed most of them: + +- `tsconfig.base.json` and `tsconfig.typecheck.json` each replaced their 18 per-package `paths` entries with a single `@deepseek-ai/dsh-*` wildcard listing one candidate per group. +- `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. +- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). + +### Guardrails added + +Two doc-sync/hygiene gates keep the structure and its references honest, so the manual checks this restructure required do not have to be repeated by hand: + +- `scripts/verify-package-paths.ts` flags a `packages/` reference (in Markdown or a `.ts` comment/string) that does not resolve **and** names a real package in a segment — i.e. a stale path to a moved package. A path naming a package that exists nowhere (a forward-looking proposal) is left alone, so the gate applies uniformly across proposed/implemented/rejected. +- `scripts/check-workspace-constraints.ts` asserts the `packages//` shape: group dirs carry no `package.json`, and no package sits flat at the root or nests deeper. Group names stay open — a new group may be added without editing the gate; only the depth-2 shape is fixed. + +## What we gave up + +The restructure churned imports, workspace globs, doc links, build references, and package paths in one coordinated move. That churn is acceptable pre-release (per the AGENTS.md foundation-over-blast-radius stance) because it stops the flat layout from fossilizing support packages as product contracts, and it is a one-time cost: the wildcard `paths`, the glob-derived publint list, and the shape gate mean a new package needs no further structural edits. diff --git a/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md deleted file mode 100644 index 5eccadaef8..0000000000 --- a/docs/rfc/proposed/architecture/2026-06-20-package-hierarchy.md +++ /dev/null @@ -1,59 +0,0 @@ -# RFC: Reorganize packages into a modular hierarchy - -Status: proposed - -## Problem - -`packages/` is flat. Core product packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all sit at the same level and look equally foundational. The [package README](../../../../packages/README.md) already has a `FIXME(package-hierarchy)` noting that `ui-stdio` and `llm-replay` were extracted from examples mostly for reuse and coverage. The flat layout makes support packages appear more product-shaped than they are and forces publish/lint/doc scripts to encode intent through comments or static lists. - -This is not just cosmetic. A package's location currently says little about whether it is core API, a swappable capability, an adapter integration, an example harness helper, or test infrastructure. That makes future removal harder because every top-level package looks like part of the same public surface. - -## Proposal - -Move packages into a deliberate hierarchy under `packages/`. The exact layout is deferred to the implementing PR, but it should group packages by modular role rather than keep every package at one flat level. - -One plausible shape: - -```text -packages/ - core/ - session/ - system-prompt/ - tools/ - agent/ - agent-loop/ - invariants/ - llm/ - llm/ - adapters/ - llm-deepseek/ - llm-pi-ai/ - bash/ - bash/ - bash-local/ - tool-bash/ - session-persistence/ - session-persistence/ - session-persistence-jsonl/ - session-persistence-sqlite/ - acp/ - support/ - ui-stdio/ - llm-replay/ -``` - -The final implementation may choose different names or groupings, but it should keep the same intent: core APIs, package families such as LLM/bash/session persistence, standalone integrations such as ACP, and support/test/example packages are distinguishable from the filesystem alone. Npm package names can stay `@deepseek-ai/dsh-*`; the hierarchy is about repo structure and maintenance policy, not public package renaming. - -This proposal does not delete `llm-replay` or `ui-stdio` by itself. It makes their status honest: either they graduate into product packages with documented consumers, or they live under a support/testing/example classification where release and compatibility expectations are lower. - -## Acceptance criteria - -- Packages move from the flat `packages//` layout into a documented modular hierarchy. -- The implementing PR chooses the exact hierarchy and updates workspace globs, TypeScript paths, package docs, generated module graphs, `cordis.yml` package paths, build scripts, and publish/lint scripts in one coordinated move. -- Scripts that publish, lint publishability, or generate package inventories use the hierarchy instead of an ad hoc static list where the hierarchy is enough to express the policy. -- Docs explain which package groups are part of the product API and which groups are support/test/example infrastructure. -- New package guidance tells authors where to place a package and discourages new one-off top-level groups. - -## What we give up - -The restructure churns imports, workspace globs, docs links, and package paths. That churn is acceptable pre-release if it prevents the flat layout from fossilizing support packages as product contracts. diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 281648eb51..784a36c593 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,20 +4,22 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) has a static list of publishable packages. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. These lists are small today, but every new package or gate creates another manual synchronization point. +Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` lists all 18 packages as explicit project `references`. These lists are small today, but every new package or gate creates another manual synchronization point. + +The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make package/gate inventories discoverable. Publishability should come from the deliberate [package hierarchy](../architecture/2026-06-20-package-hierarchy.md) plus package manifests, not from a static array in a script or the npm `private` flag. Module graph generation should read package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive `tsconfig.build.json`'s `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. ## Acceptance criteria -- `publint-all` discovers publishable packages from the hierarchy plus manifests instead of a hard-coded array. -- Adding a package does not require editing a static package list for every gate. +- `tsconfig.build.json` project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. +- Adding a package does not require editing a static package list for any gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 735e23a156..62d37a3354 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -7,12 +7,12 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning. - **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). -- **Tests**: vitest in `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env. +- **Tests**: vitest in `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env. Naming notes: - A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above) - `src/types.ts` contain only types — no runtime code - Tests live at package level under `tests/`, not `src/__tests__/` -- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). +- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md` and `packages/*/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. diff --git a/packages/README.md b/packages/README.md index 68d7600c85..139050683d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,13 +2,20 @@ Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin that gets registered via `ctx.plugin()`, declares its ctx key/events where applicable through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. - +## Hierarchy + +Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure. + +| Group | Role | Release expectation | +|---|---|---| +| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | +| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`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 | + +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). ## Dependency graph @@ -34,23 +41,26 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l ## What goes where -| Package | Role | ctx key | -|---|---|---| -| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | -| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | -| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | -| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | -| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | -| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | -| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | -| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | -| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | -| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | -| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | -| `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` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | +| Package | Group | Role | ctx key | +|---|---|---|---| +| `llm/` | `llm` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | +| `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` | +| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | +| `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | +| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | +| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | +| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | +| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | +| `session-persistence-jsonl/` | `session-persistence` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | +| `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | +| `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | +| `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `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`) | Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). @@ -61,4 +71,4 @@ Each package has its own `README.md` with purpose, service API, events, extensio - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. - **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package. -- **Tests**: vitest, colocated under `packages//tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. +- **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/bash/README.md b/packages/bash/README.md new file mode 100644 index 0000000000..9a9dba88d5 --- /dev/null +++ b/packages/bash/README.md @@ -0,0 +1,11 @@ +# bash/ — bash capability family + +The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | +| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | + +The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000000..9c5411fd5f --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,13 @@ +# core/ — product API spine + +The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against. + +| Package | Role | ctx key | +|---|---|---| +| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | +| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | +| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | +| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | +| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | + +`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000000..3fc5c9cf9e --- /dev/null +++ b/packages/llm/README.md @@ -0,0 +1,11 @@ +# llm/ — LLM capability family + +The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | +| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | +| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | + +The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist. diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md new file mode 100644 index 0000000000..603e525ee0 --- /dev/null +++ b/packages/session-persistence/README.md @@ -0,0 +1,11 @@ +# session-persistence/ — persistence capability family + +The durable session-persistence seam and its storage backends. The interface package owns the abstract `SessionPersistence` service and the shared write coordinator; the backends are concrete implementations that register on `ctx.sessionPersistence`. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` | +| `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) | +| `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | + +The interface lives at `session-persistence/session-persistence/`; backends are flat siblings. A new storage backend joins here and registers on `ctx.sessionPersistence`. See [session persistence](../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). diff --git a/packages/support/README.md b/packages/support/README.md new file mode 100644 index 0000000000..52f7f6fe25 --- /dev/null +++ b/packages/support/README.md @@ -0,0 +1,11 @@ +# support/ — dev/test/example infrastructure + +Packages that exist to serve development, testing, and the examples rather than to ship as product API. They are real workspace packages (typed, tested, under the coverage gate), but they carry **lower compatibility expectations**: they may change or be removed when the development need behind them does, without the deprecation care a product package would warrant. + +| 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`) | + +`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. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000000..62b2c70855 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,9 @@ +# ui/ — editor/client integration surfaces + +Integrations that expose the agent to an external editor or client. These are **product** packages: a real surface a user drives the harness through. + +| Package | Role | ctx key | +|---|---|---| +| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | + +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. From 94a762658ff53a817de40504907fa6beafa81cd0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:37:41 +0800 Subject: [PATCH 55/87] docs(rfc): reject the superseded providerless-example-base RFC git mv it from proposed/ to rejected/architecture/ (preserving history) and fold the supersede note into a one-line rejected Status. The extract-example-app-packages RFC subsumes it: once the spine moves into dsh-agent-core and the base*.yml files are deleted, there is no shared base YAML left to rename. Move its README row to Rejected -> Architecture and repoint the supersede cross-link to the new path. --- docs/rfc/README.md | 2 +- .../architecture/2026-06-20-extract-example-app-packages.md | 2 +- .../architecture/2026-06-20-providerless-example-base.md | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) rename docs/rfc/{proposed => rejected}/architecture/2026-06-20-providerless-example-base.md (88%) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 83f4a2e6db..997683fab5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -63,7 +63,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | @@ -163,3 +162,4 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [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/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index e129acf64e..a99fa80f86 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -39,6 +39,6 @@ The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML ## Related -- Supersedes [Make the shared example base providerless](2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. - Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. diff --git a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md similarity index 88% rename from docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md rename to docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index fae44635d0..c83824d21c 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,8 +1,6 @@ # RFC: Make the shared example base providerless -Status: proposed - -> Superseded by [Extract example apps into packages](2026-06-20-extract-example-app-packages.md): moving the spine into a `dsh-agent-core` bundle and deleting the `base*.yml` files makes the rename below moot. Kept for the record. +Status: rejected — superseded by [Extract example apps into packages](../../proposed/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem From 184066e745a7a3f338da3184c06b1a7306bb2341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:00:38 +0800 Subject: [PATCH 56/87] Tighten implemented RFC after self-review Self-audited every factual claim against the shipped branch (hierarchy tree, no group manifests, the dsh-* paths wildcard in both tsconfigs, publint glob, explicit build references, the two new gates). Phrase the paths-wildcard bullet as current state, and record the doc-typecheck JSONC-parse subtlety the wildcard introduced so a future editor does not reintroduce the regex comment-strip bug. --- .../implemented/architecture/2026-06-20-package-hierarchy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md index 0b9ff5cbfb..60e295e767 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -49,9 +49,9 @@ packages/ ### Deduplicating the package lists -The package list had been enumerated in five places. The uniform depth-2 layout collapsed most of them: +The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: -- `tsconfig.base.json` and `tsconfig.typecheck.json` each replaced their 18 per-package `paths` entries with a single `@deepseek-ai/dsh-*` wildcard listing one candidate per group. +- `tsconfig.base.json` and `tsconfig.typecheck.json` each map every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of 18 per-package entries. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the `paths` map via the TypeScript JSONC API rather than stripping comments by hand for exactly this reason.) - `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. - `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). From e42493b7a71bde7c94fd5c53dfe9b6c88cb5c86e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:08:45 +0800 Subject: [PATCH 57/87] Fix package-path drift in master's new RFCs after merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought in two new proposed/architecture RFCs that reference the old flat packages/ paths and the package-hierarchy RFC's old proposed/ location. Rewrite their package paths to the grouped layout and repoint the cross-link to implemented/architecture/ — the verify-package-paths and verify-md-links gates caught both. --- .../architecture/2026-06-20-branded-ids.md | 14 +++++++------- .../2026-06-20-extract-example-app-packages.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index 6f92de519a..93a4bf6cda 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -4,21 +4,21 @@ Status: proposed ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/src/brand.ts`), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Proposal A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) - **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. @@ -42,7 +42,7 @@ export function OwnerToken(id: string): OwnerToken { ## Why a distinct OwnerToken brand (not SessionId) -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index a99fa80f86..5c99e7a197 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -12,7 +12,7 @@ The deeper problem is a **coupled front-door cluster** that lives at the leaf wi Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-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`. This is today's [base-core.yml](../../../../examples/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 (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/agent-loop/src/index.ts](../../../../packages/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today 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. +- **`@deepseek-ai/dsh-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`. This is today's [base-core.yml](../../../../examples/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 (default `[]`, exactly 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 [base-core.yml](../../../../examples/base-core.yml) gives today 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. - **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. - **Drop `start.ts`.** Each app package exposes a `bin`; 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 move into that bin, owned by the app. - **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle 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). A handful of entries, no infra preamble. @@ -41,4 +41,4 @@ The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML - Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. - Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. -- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. +- Complements [Reorganize packages into a modular hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md): the new app/core packages slot into a group under that hierarchy (a product group for the reusable core bundle, or alongside the examples for app-specific wiring). From 30cd67b8a1d0d18b68f4b474e4cd261b899353a3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:27:41 +0800 Subject: [PATCH 58/87] simplify(llm): drop unconsumed adapter-change event and assembled call surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM service exposed three call surfaces (stream/streamBlocks/generate) but the only production consumer — the agent loop — uses stream() exclusively, feeding raw chunks through its own BlockAssembler for replay fidelity. Drop the speculative convenience surfaces and the registry-change event that no listener consumed, leaving stream() as the single model-call contract for both production and tests. - Remove LlmService.streamBlocks() and generate(), the llm/generate waterfall, and GenerateResult. - Remove the llm/adapter-change event (declaration + emits) and the listener-throw rollback ordering that existed only to protect it; keep the HMR rollback disposer. - Remove BlockAssembler.flushReady()/flushRemaining()/result() and the flushed cursor — the streaming-flush slice existed only for streamBlocks(). - Adapter tests drive a stream()+BlockAssembler helper (tests/assemble.ts) instead of generate(), exercising the same path production uses. - Land the AGENTS.md "RFCs are proposals, not golden truth" principle and move both RFCs proposed -> implemented. Implements: - docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md - docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md --- AGENTS.md | 6 ++ docs/architecture.md | 2 +- docs/cordis-catalog/events-and-services.md | 32 +------ docs/core-data-structures/core.md | 12 +-- docs/core-data-structures/llm-streaming.md | 2 +- docs/rfc/README.md | 4 +- .../2026-06-11-microkernel-event-taxonomy.md | 2 +- ...rop-unconsumed-llm-adapter-change-event.md | 2 +- ...-drop-unconsumed-llm-assembled-surfaces.md | 2 +- .../2026-06-11-property-based-testing.md | 4 +- .../agent-loop/tests/review-fixes.spec.ts | 86 +------------------ .../llm/llm-deepseek/tests/adapter.e2e.ts | 15 ++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 23 ++--- packages/llm/llm-deepseek/tests/assemble.ts | 26 ++++++ .../llm/llm-deepseek/tests/translate.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 19 ++-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 37 ++++---- packages/llm/llm-pi-ai/tests/assemble.ts | 26 ++++++ packages/llm/llm/README.md | 13 +-- packages/llm/llm/src/assembler.ts | 58 ++----------- packages/llm/llm/src/index.ts | 61 ++----------- packages/llm/llm/src/types.ts | 7 -- packages/llm/llm/tests/assembler.spec.ts | 50 ++--------- packages/llm/llm/tests/properties.spec.ts | 42 +-------- packages/llm/llm/tests/service.spec.ts | 58 +++---------- scripts/type-equiv.manifest.json | 1 - 26 files changed, 164 insertions(+), 428 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md (98%) rename docs/rfc/{proposed => implemented}/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md (99%) create mode 100644 packages/llm/llm-deepseek/tests/assemble.ts create mode 100644 packages/llm/llm-pi-ai/tests/assemble.ts diff --git a/AGENTS.md b/AGENTS.md index fb1750b423..dc4e65a962 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ Before you preserve a behavior solely to keep a test green, ask: is this behavio The worked example is [Drop the mutable session summary](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md): an entire `SessionSummary` type, a `SessionPersistence.update()` method, a JSONL sidecar, and SQLite columns existed and were exercised by their own contract test — yet **nothing in production CONSUMED any of it, and `update()` had no production caller**. (The backends did *write* summary state — JSONL touched the sidecar after a durable append, SQLite bumped `updated_at` in the append transaction — but those writes fed only reads that nothing performed.) The tests documented the behavior perfectly; the behavior was dead. Deleting the behavior and its tests together removed ~400 lines and erased a durability divergence the next refactor would have had to model. (This is the test-tier echo of "verify the world, not a synthetic stand-in" in § Defensive patterns: a test agrees with whatever it was written to assert; only a real consumer proves the behavior matters.) +## RFCs are proposals, not golden truth + +The same discipline applies one level up, to the RFCs in `docs/rfc/`. A **proposed** RFC records an *intended* change argued at a point in time; it is not a contract to implement verbatim. The author reasoned from the code as they understood it then — and they can be wrong, or the code can have moved. So before implementing an RFC, **validate its premise against the current code first**: confirm the thing it wants removed or changed is actually dead/safe, and that the migration it proposes is genuinely cleaner than what exists. + +When carrying out the change fights back — a removal forces an awkward migration, deletes machinery that turns out to be load-bearing, or pushes consumers onto a more brittle hand-rolled equivalent — treat that friction as **evidence the RFC over-reached**, not as work to push through. Keep, split, or amend the change to match what the code actually wants, and say so in the PR. An RFC that ships in amended form gets its text amended on the way to `implemented/`, so the landed RFC describes what actually shipped rather than the original guess. The discipline cuts both ways: an RFC is also not a reason to *avoid* a change a maintainer would otherwise make — it is one input, weighed against the code in front of you. + ## Architecture This codebase is based on the **Cordis** framework, built microkernel-style: **everything is a plugin**. All necessary Cordis dependencies are copied into this monorepo as vendored source (under `vendor/`) instead of being depended on via npm. diff --git a/docs/architecture.md b/docs/architecture.md index 2bc782cd43..7c7e891a78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` | ctx key | Class | Package | Role | |---|---|---|---| -| `ctx.llm` | `LlmService` | dsh-llm | adapter registry; `stream()` / `streamBlocks()` / `generate()` | +| `ctx.llm` | `LlmService` | dsh-llm | adapter registry; `stream()` | | `ctx.sessions` | `SessionStore` | dsh-session | creates/holds event-sourced `Session`s | | `ctx.sessionPersistence` | `SessionPersistence` (abstract) | dsh-session-persistence | durable persistence seam: create/append/load/list sessions | | `ctx.systemPrompt` | `SystemPrompt` | dsh-system-prompt | ordered sections + tool schemas → `assemble()` | diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index d6ca9f9850..f651867c89 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 5 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes. ### `agent/*` @@ -185,28 +185,6 @@ Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/t ### `llm/*` -#### `llm/adapter-change` — emit - -An adapter was registered or unregistered (the model→adapter map changed). - -```ts cordis-catalog -'llm/adapter-change'(): void -``` - -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) - -#### `llm/generate` — waterfall - -Waterfall around every non-streaming model call. Bound to the LlmService; call `next()` to delegate to the adapter. - -```ts cordis-catalog -'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise -``` - -Types: [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) - -Source: [`packages/llm/llm/src/index.ts:38`](../../packages/llm/llm/src/index.ts) - #### `llm/stream` — waterfall Waterfall around every streaming model call (retry, caching, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. @@ -217,7 +195,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:32`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:31`](../../packages/llm/llm/src/index.ts) ### `session/*` @@ -369,13 +347,11 @@ The abstract `llm` service: an adapter registry plus streaming / non-streaming c registerAdapter(models: string[], adapter: LlmAdapter): () => void models(): string[] stream(options: GenerateOptions): AsyncIterable -async * streamBlocks(options: GenerateOptions): AsyncIterable -generate(options: GenerateOptions): Promise ``` -Types: [ContentBlock](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:81`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index bcac8010f0..a79255a5c7 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -112,9 +112,9 @@ Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelit The full union, the adapter contract (usage-before-finish, raw-JSON tool arguments, the two sanctioned error paths), and `BlockAssembler` live on **[llm-streaming.md](llm-streaming.md)**. -## The model request and result +## The model request -One model call is a fully-assembled `GenerateOptions`; the non-streaming result is `GenerateResult`. +One model call is a fully-assembled `GenerateOptions`. The adapter answers with a raw `StreamChunk` stream; the consumer assembles it with `BlockAssembler` (see [llm-streaming.md](llm-streaming.md)). Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -140,14 +140,6 @@ interface GenerateOptions { } ``` -```ts type-equiv -interface GenerateResult { - message: Message - usage?: TokenUsage - finish: FinishReason -} -``` - Why a model response stopped is a merge-extensible reason: ```ts type-equiv diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 1019e84a16..7439eb14a6 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -49,7 +49,7 @@ interface TokenUsage { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, register with `ctx.llm.registerAdapter(models, adapter)`. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()` / `streamBlocks()` / `generate()`) and the `llm/stream` waterfall are described in [architecture.md § The vocabulary](../architecture.md#the-vocabulary-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, register with `ctx.llm.registerAdapter(models, adapter)`. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § The vocabulary](../architecture.md#the-vocabulary-dsh-llm). `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 3778aaf4ad..21036c5769 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -52,8 +52,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 | | [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Keep one public stop primitive](proposed/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Drop unconsumed assembled LLM convenience surfaces](proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | -| [Drop the unconsumed `llm/adapter-change` event](proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | | [Prune dead methods from the persistence and bash seams](proposed/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | @@ -96,6 +94,8 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | 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 | +| [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 942445a429..c8869c0616 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -12,7 +12,7 @@ The product principle (see the 微内核Harness实现思路 design doc) is "ever Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes: -- **waterfall** (around-middleware) where plugins mutate or veto: `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/execute`, `llm/stream`, `llm/generate`, `system-prompt/assemble`. +- **waterfall** (around-middleware) where plugins mutate or veto: `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/execute`, `llm/stream`, `system-prompt/assemble`. - **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors. - **parallel** (awaited) for the one durability checkpoint: `session/flush`. diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md similarity index 98% rename from docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md rename to docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index bf5eb3bed2..5ed2f0da68 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -1,6 +1,6 @@ # RFC: Drop the unconsumed `llm/adapter-change` event -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem diff --git a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md similarity index 99% rename from docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md rename to docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index cf93b3e83a..74d37bf75a 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -1,6 +1,6 @@ # RFC: Drop unconsumed assembled LLM convenience surfaces -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 19e371b1b0..d737379b27 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -8,13 +8,13 @@ Status: implemented (proposed 2026-06-11, accepted 2026-06-14) ## Context -Example-based tests pin the cases we thought of. The harness's core is protocol-shaped — chunk streams, event logs, schema conversion, inbox scheduling — where the input space is combinatorial and the interesting bugs live in interleavings nobody wrote an example for. The motivating evidence: a `streamBlocks` ordering bug once survived 100% line coverage of the happy paths. Per-file 100% coverage proves every line ran, not that every interleaving is correct. +Example-based tests pin the cases we thought of. The harness's core is protocol-shaped — chunk streams, event logs, schema conversion, inbox scheduling — where the input space is combinatorial and the interesting bugs live in interleavings nobody wrote an example for. The motivating evidence: a block-assembly ordering bug once survived 100% line coverage of the happy paths. Per-file 100% coverage proves every line ran, not that every interleaving is correct. ## Decision Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` per protocol-shaped package, generators tuned for *realistic-but-adversarial* inputs (not uniform noise) and `numRuns` kept so the suite stays well under ~10s locally. Failures print a reproducible seed. (The original proposal also sketched a nightly CI job running 100× the iterations; that was not shipped — the property suite runs only in the normal `push`/`pull_request` CI, and a scheduled high-iteration job remains possible future work.) -- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `flushReady()+flushRemaining() ≡ blocks()` in order; the streamed prefix is always a prefix of final `blocks()`; partial count ≤ distinct indices; re-assembly idempotent. +- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: the blocks `push()` returns incrementally are a prefix of the final `blocks()`, in order; partial count ≤ distinct indices; re-assembly idempotent; streaming and one-shot consumers agree on usage and finish. - **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log. - **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk. - **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine. diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 3f65ae737e..8fc375457a 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' @@ -446,90 +446,6 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () }) }) -describe('LOW: BlockAssembler and streamBlocks edge cases', () => { - it('ignores deltas arriving after block-end for the same index (malformed stream)', async () => { - const { BlockAssembler } = await import('@deepseek-ai/dsh-llm') - const assembler = new BlockAssembler() - assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) - assembler.push({ type: 'text-delta', index: 0, text: 'good' }) - assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'good' } }) - assembler.push({ type: 'text-delta', index: 0, text: ' straggler' }) - expect(assembler.blocks()).toEqual([{ type: 'text', text: 'good' }]) - }) - - it('assembles tool-call blocks from deltas without block-end', async () => { - const { BlockAssembler } = await import('@deepseek-ai/dsh-llm') - const assembler = new BlockAssembler() - assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), name: 'echo', argumentsDelta: '{"a"' }) - assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), argumentsDelta: ':1}' }) - expect(assembler.blocks()).toEqual([ - { type: 'tool-call', id: CallId('c9'), name: 'echo', arguments: '{"a":1}' }, - ]) - }) - - it('streamBlocks flushes delta-only blocks at end of stream (matches generate())', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - const deltaOnly: StreamChunk[] = [ - { type: 'text-delta', index: 0, text: 'no ' }, - { type: 'text-delta', index: 0, text: 'block-end' }, - { type: 'finish', reason: { kind: 'stop' } }, - ] - ctx.llm.registerAdapter(['m'], new MockAdapter([deltaOnly, deltaOnly])) - - const blocks: ContentBlock[] = [] - for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block) - expect(blocks).toEqual([{ type: 'text', text: 'no block-end' }]) - - const generated = await ctx.llm.generate({ model: 'm', messages: [] }) - expect(generated.message.content).toEqual(blocks) - }) - - it('streamBlocks preserves stream order when an open block precedes a closed one', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - // index 0 never gets block-end (delta-only); index 1 closes mid-stream. - const interleaved: StreamChunk[] = [ - { type: 'text-delta', index: 0, text: 'first, open' }, - { type: 'block-start', index: 1, blockType: 'text' }, - { type: 'text-delta', index: 1, text: 'second, closed' }, - { type: 'block-end', index: 1, block: { type: 'text', text: 'second, closed' } }, - { type: 'finish', reason: { kind: 'stop' } }, - ] - ctx.llm.registerAdapter(['m'], new MockAdapter([interleaved, interleaved])) - - const blocks: ContentBlock[] = [] - for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block) - expect(blocks).toEqual([ - { type: 'text', text: 'first, open' }, - { type: 'text', text: 'second, closed' }, - ]) - - // identical to generate()'s assembled order - const generated = await ctx.llm.generate({ model: 'm', messages: [] }) - expect(generated.message.content).toEqual(blocks) - }) - - it('streamBlocks yields closed blocks incrementally once preceding blocks close', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - const script: StreamChunk[] = [ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'text-delta', index: 0, text: 'a' }, - { type: 'block-end', index: 0, block: { type: 'text', text: 'a' } }, - { type: 'block-start', index: 1, blockType: 'text' }, - { type: 'text-delta', index: 1, text: 'b' }, - { type: 'block-end', index: 1, block: { type: 'text', text: 'b' } }, - { type: 'finish', reason: { kind: 'stop' } }, - ] - ctx.llm.registerAdapter(['m'], new MockAdapter([script])) - - const blocks: ContentBlock[] = [] - for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block) - expect(blocks).toEqual([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]) - }) -}) - describe('LOW: discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { const session = new Session(SessionId('s')) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index ebec6e62ce..b01b498dff 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' +import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across @@ -31,7 +32,7 @@ function ask(text: string): Message[] { return [{ role: 'user', content: [{ type: 'text', text }] }] } -function textOf(result: GenerateResult): string { +function textOf(result: AssembledResult): string { return result.message.content .filter(block => block.type === 'text') .map(block => block.text) @@ -51,7 +52,7 @@ const weatherTool: ToolSchema = { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { it('flash + thinking disabled: plain text generation', async () => { const ctx = await harness(FLASH, { thinking: 'disabled' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: FLASH, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, @@ -65,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => { const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: FLASH, messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, @@ -82,7 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort }) // Turn 1: the model must call the tool (and think before it). - const first = await ctx.llm.generate({ + const first = await assemble(ctx,{ model: PRO, messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], @@ -96,7 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () // Turn 2: send the tool result back WITH the assistant's reasoning // block in history (the official thinking+tools passback rule). - const second = await ctx.llm.generate({ + const second = await assemble(ctx,{ model: PRO, messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), @@ -120,7 +121,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () it('pro + thinking disabled: plain generation without reasoning blocks', async () => { const ctx = await harness(PRO, { thinking: 'disabled' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: PRO, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 553affecb7..1abbebc060 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -5,6 +5,7 @@ import { Context } from 'cordis' import LlmService, { LlmError } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ type Behavior = @@ -90,11 +91,11 @@ async function harness(baseURL: string, config: object = {}) { } describe('DeepSeekAdapter against a mock server', () => { - it('streams a text generation end to end through ctx.llm.generate', async () => { + it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url) - const result = await ctx.llm.generate({ + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], }) @@ -130,7 +131,7 @@ describe('DeepSeekAdapter against a mock server', () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) - await ctx.llm.generate({ + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], }) @@ -155,15 +156,15 @@ describe('DeepSeekAdapter against a mock server', () => { } const server = await mockServer([behavior, behavior, behavior]) const ctx = await harness(server.url) - await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })) + await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) await expect( - ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) // The numeric HTTP status is carried on the error for explicit handling. await expect( - ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).status), ).resolves.toBe(status) }) @@ -171,14 +172,14 @@ describe('DeepSeekAdapter against a mock server', () => { it('keeps the status-line message for JSON error bodies without a message', async () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) - await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })) + await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(/HTTP 500/) }) it('keeps the status-line message for non-JSON error bodies', async () => { const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) const ctx = await harness(server.url) - await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })) + await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(/HTTP 502/) }) @@ -207,7 +208,7 @@ describe('DeepSeekAdapter against a mock server', () => { events: ['{"choices":[{"delta":{"content":"par"}}]}'], }]) const ctx = await harness(server.url) - await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })) + await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(/terminated|socket|without \[DONE\]/) }) @@ -278,7 +279,7 @@ describe('plugin registration and config', () => { vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url) // harness passes explicit config - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests).toHaveLength(1) // hit the explicit URL, not env }) @@ -288,7 +289,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] }) - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests).toHaveLength(1) }) diff --git a/packages/llm/llm-deepseek/tests/assemble.ts b/packages/llm/llm-deepseek/tests/assemble.ts new file mode 100644 index 0000000000..b0182615e0 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/assemble.ts @@ -0,0 +1,26 @@ +/** + * Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return + * the assembled message + usage + finish reason. This exercises the same + * streaming path production uses (the loop), rather than a service-level + * one-shot convenience method. + */ + +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import type { Context } from 'cordis' +import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' + +export interface AssembledResult { + message: Message + usage?: TokenUsage + finish: FinishReason +} + +export async function assemble(ctx: Context, options: GenerateOptions): Promise { + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) + return { + message: assembler.message(), + ...assembler.usage !== undefined ? { usage: assembler.usage } : {}, + finish: assembler.finish, + } +} diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index 40b5ee5944..d6968faed5 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -47,7 +47,7 @@ describe('translate: text', () => { ))) { assembler.push(chunk) } - const result = assembler.result() + const result = { message: assembler.message(), finish: assembler.finish } expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) expect(result.finish).toEqual({ kind: 'stop' }) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 133539f6be..fa30226ddf 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { Config } from '@deepseek-ai/dsh-llm-pi-ai' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all @@ -33,14 +34,14 @@ function ask(text: string): Message[] { return [{ role: 'user', content: [{ type: 'text', text }] }] } -function textOf(result: GenerateResult): string { +function textOf(result: AssembledResult): string { return result.message.content .filter(block => block.type === 'text') .map(block => block.text) .join('') } -function blockKinds(result: GenerateResult): string[] { +function blockKinds(result: AssembledResult): string[] { return result.message.content.map(block => block.type) } @@ -57,7 +58,7 @@ const weatherTool: ToolSchema = { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => { it.each([FLASH, PRO])('%s + reasoning off: plain text generation', async (model) => { const ctx = await harness(model, { reasoning: 'off' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, @@ -69,7 +70,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => { const ctx = await harness(model, { reasoning: 'high' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model, messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, @@ -82,7 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => it('pro + reasoning xhigh (wire max): tool-call round trip', async () => { const ctx = await harness(PRO, { reasoning: 'xhigh' }) - const first = await ctx.llm.generate({ + const first = await assemble(ctx,{ model: PRO, messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], @@ -94,7 +95,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(call!.name).toBe('get_weather') expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) - const second = await ctx.llm.generate({ + const second = await assemble(ctx,{ model: PRO, messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), @@ -128,8 +129,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => const prompt = ask('Reply with exactly the word: pong') const [fromDeepSeek, fromPiAi] = await Promise.all([ - deepseekCtx.llm.generate({ model: FLASH, messages: prompt, maxTokens: 50 }), - piCtx.llm.generate({ model: FLASH, messages: prompt, maxTokens: 50 }), + assemble(deepseekCtx, { model: FLASH, messages: prompt, maxTokens: 50 }), + assemble(piCtx, { model: FLASH, messages: prompt, maxTokens: 50 }), ]) expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek)) expect(fromPiAi.finish.kind).toBe(fromDeepSeek.finish.kind) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 97b20f4617..63f9f90456 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -5,6 +5,7 @@ import { Context } from 'cordis' import LlmService, { CallId, LlmError } 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' /** Scripted SSE responses, one per request (OpenAI chat-completions shape). */ interface MockServer { @@ -79,11 +80,11 @@ async function harness(baseURL: string, config: object = {}) { } describe('PiAiAdapter against a mock server', () => { - it('streams a text generation through ctx.llm.generate', async () => { + it('streams a text generation through the assembler', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) - const result = await ctx.llm.generate({ + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], }) @@ -96,7 +97,7 @@ describe('PiAiAdapter against a mock server', () => { const server = await mockServer([{ events: toolEvents }]) const ctx = await harness(server.url) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'weather?' }] }], tools: [{ @@ -114,7 +115,7 @@ describe('PiAiAdapter against a mock server', () => { const server = await mockServer([{ events: thinkingEvents }]) const ctx = await harness(server.url, { reasoning: 'high' }) - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'think' }] }], }) @@ -127,7 +128,7 @@ describe('PiAiAdapter against a mock server', () => { it('sends DeepSeek thinking fields when reasoning is configured', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { reasoning: 'xhigh' }) - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, reasoning_effort: 'max', // xhigh maps to max via thinkingLevelMap @@ -137,21 +138,21 @@ describe('PiAiAdapter against a mock server', () => { it('disables thinking for reasoning: off', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { reasoning: 'off' }) - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests[0]).toMatchObject({ thinking: { type: 'disabled' } }) }) it('injects stop sequences through onPayload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [], stop: ['END'] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], stop: ['END'] }) expect(server.requests[0]).toMatchObject({ stop: ['END'] }) }) it('preserves per-tool strict exactly through onPayload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) - await ctx.llm.generate({ + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], tools: [ @@ -173,7 +174,7 @@ describe('PiAiAdapter against a mock server', () => { it('preserves raw replayed tool-call arguments in the provider payload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) - await ctx.llm.generate({ + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [{ role: 'assistant', @@ -192,7 +193,7 @@ describe('PiAiAdapter against a mock server', () => { body: JSON.stringify({ error: { message: 'bad key' } }), }]) const ctx = await harness(server.url) - const result = await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', code: 'AUTH' }) expect((result.finish as { message: string }).message).toMatch(/bad key|401/) }) @@ -204,13 +205,13 @@ describe('PiAiAdapter against a mock server', () => { ] as const)('maps HTTP %s to stable error code %s', async (status, code) => { const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }]) const ctx = await harness(server.url) - const result = await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) 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(ctx.llm.generate({ + await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], prefill: [{ type: 'text', text: 'Sure' }], @@ -244,7 +245,7 @@ describe('option spreads and env fallbacks', () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) const controller = new AbortController() - await ctx.llm.generate({ + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], temperature: 0.5, @@ -262,7 +263,7 @@ describe('option spreads and env fallbacks', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { models: ['deepseek-v4-flash'] }) - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) expect(server.requests).toHaveLength(1) } finally { vi.unstubAllEnvs() @@ -311,7 +312,7 @@ describe('review fixes', () => { it('defaults omitted reasoning config to thinking ENABLED (provider default)', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) // no reasoning key at all - await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) const request = server.requests[0] as Record expect(request.thinking).toEqual({ type: 'enabled' }) expect('reasoning_effort' in request).toBe(false) @@ -320,7 +321,7 @@ describe('review fixes', () => { it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) - await ctx.llm.generate({ + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [ { role: 'user', content: [{ type: 'text', text: 'weather?' }] }, @@ -376,7 +377,7 @@ describe('review fixes: abort wiring', () => { const controller = new AbortController() controller.abort('already cancelled') // pi-ai surfaces the abort as an in-stream error event → aborted finish. - const result = await ctx.llm.generate({ + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], signal: controller.signal, @@ -388,7 +389,7 @@ describe('review fixes: abort wiring', () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) const controller = new AbortController() - const pending = ctx.llm.generate({ + const pending = assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], signal: controller.signal, diff --git a/packages/llm/llm-pi-ai/tests/assemble.ts b/packages/llm/llm-pi-ai/tests/assemble.ts new file mode 100644 index 0000000000..b0182615e0 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/assemble.ts @@ -0,0 +1,26 @@ +/** + * Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return + * the assembled message + usage + finish reason. This exercises the same + * streaming path production uses (the loop), rather than a service-level + * one-shot convenience method. + */ + +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import type { Context } from 'cordis' +import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' + +export interface AssembledResult { + message: Message + usage?: TokenUsage + finish: FinishReason +} + +export async function assemble(ctx: Context, options: GenerateOptions): Promise { + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) + return { + message: assembler.message(), + ...assembler.usage !== undefined ? { usage: assembler.usage } : {}, + finish: assembler.finish, + } +} diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 4326c5a1f8..4227f5fdef 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -4,28 +4,24 @@ Provider-neutral LLM vocabulary and abstract service. This package defines the c ## Service: `LlmService` (ctx key: `llm`) -An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events. +An adapter registry plus a single streaming call surface, interceptable via a waterfall event. ### Public API - `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber. - `ctx.llm.models(): string[]` — model names with a registered adapter. -- `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). -- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable` Stream as completed content blocks (convenience view). -- `ctx.llm.generate(options: GenerateOptions): Promise` One model call, fully assembled. +- `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. ### Events | Event | Mode | Purpose | |---|---|---| | `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) | -| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call | -| `llm/adapter-change` | emit | An adapter was registered or unregistered | ### Extension points - Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider. -- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. +- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. ### Content-block vocabulary (`types.ts`) @@ -36,8 +32,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Classes - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. -- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay - + assembled for history) and by `streamBlocks()`/`generate()`. +- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. - `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index a61d6cf044..328ef01c54 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -1,13 +1,14 @@ /** * Incremental chunk-to-message assembler. This is the single canonical assembly - * algorithm used by both the agent loop and the LLM service convenience views. + * algorithm used by the agent loop to build an assistant message from a chunk + * stream while logging the raw chunks for replay fidelity. * * @module @deepseek-ai/dsh-llm/assembler */ import { CallId } from './brand.ts' import { assertNever } from './never.ts' -import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts' +import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types.ts' interface PartialBlock { blockType: string @@ -23,9 +24,8 @@ interface PartialBlock { * Incrementally assembles raw {@link StreamChunk}s into complete * {@link ContentBlock}s and a final assistant {@link Message}. * - * This is the single shared assembly implementation: the agent loop feeds it - * while logging raw chunks for replay fidelity, and `LlmService.generate()` / - * `streamBlocks()` use it to offer assembled views of the same stream. + * The agent loop feeds it while logging raw chunks for replay fidelity, then + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -34,7 +34,6 @@ interface PartialBlock { export class BlockAssembler { private partials = new Map() private order: number[] = [] - private flushed = 0 private _usage: TokenUsage | undefined private _finish: FinishReason | undefined @@ -129,44 +128,6 @@ export class BlockAssembler { return this.order.map(index => this.assemble(this.mustGet(index), index)) } - /** - * Streaming flush: returns (once) every block that is complete AND has no - * incomplete block before it in stream order. Call after each `push()`; - * blocks come out strictly in stream order, so a streaming consumer sees - * exactly the sequence `blocks()` would produce. - */ - flushReady(): ContentBlock[] { - const ready: ContentBlock[] = [] - while (this.flushed < this.order.length) { - const index = this.order[this.flushed] - /* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists in a non-empty array */ - if (index === undefined) break - const partial = this.mustGet(index) - if (!partial.block) break - ready.push(partial.block) - this.flushed += 1 - } - return ready - } - - /** - * End-of-stream flush: returns (once) all not-yet-flushed blocks, in stream - * order, assembling still-open ones from their deltas (delta-only - * protocols). After this, `flushReady()` + `flushRemaining()` together have - * yielded exactly `blocks()`. - */ - flushRemaining(): ContentBlock[] { - const remaining: ContentBlock[] = [] - while (this.flushed < this.order.length) { - const index = this.order[this.flushed] - /* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists */ - if (index === undefined) break - remaining.push(this.assemble(this.mustGet(index), index)) - this.flushed += 1 - } - return remaining - } - get usage(): TokenUsage | undefined { return this._usage } @@ -179,13 +140,4 @@ export class BlockAssembler { message(): Message { return { role: 'assistant', content: this.blocks() } } - - /** The assembled non-streaming result. */ - result(): GenerateResult { - return { - message: this.message(), - ...this._usage !== undefined ? { usage: this._usage } : {}, - finish: this.finish, - } - } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index aaa0f66460..e463e631d6 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -1,14 +1,13 @@ /** - * LLM service: adapter registry with waterfall-interceptable streaming and - * non-streaming call surfaces. Exports the `LlmService` default, the abstract - * `LlmAdapter` for provider backends, and `BlockAssembler` for chunk assembly. + * LLM service: adapter registry with a waterfall-interceptable streaming call + * surface. Exports the `LlmService` default, the abstract `LlmAdapter` for + * provider backends, and `BlockAssembler` for chunk assembly. * * @module @deepseek-ai/dsh-llm */ import { Context, Service } from 'cordis' -import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts' -import { BlockAssembler } from './assembler.ts' +import type { GenerateOptions, StreamChunk } from './types.ts' import { HarnessError } from './error.ts' export * from './brand.ts' @@ -30,17 +29,6 @@ declare module 'cordis' { * @mode waterfall */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable - /** - * Waterfall around every non-streaming model call. Bound to the - * {@link LlmService}; call `next()` to delegate to the adapter. - * @mode waterfall - */ - 'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise - /** - * An adapter was registered or unregistered (the model→adapter map changed). - * @mode emit - */ - 'llm/adapter-change'(): void } } @@ -88,8 +76,7 @@ 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). - * Emits `llm/adapter-change` on registration and disposal. Disposed with the - * fiber. + * Disposed with the fiber. */ registerAdapter(models: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { @@ -99,17 +86,9 @@ export class LlmService extends Service { } } for (const model of models) this.adapters.set(model, adapter) - // Yield the rollback BEFORE emitting the change event: a generator effect - // collects each yielded disposer before running the next step, so a - // throwing `llm/adapter-change` listener rolls the mutation back instead - // of leaking the entry (which would wedge the duplicate check until - // restart). The duplicate throws above fire before any mutation, so they - // correctly leak nothing. yield () => { for (const model of models) this.adapters.delete(model) - this.ctx.emit('llm/adapter-change') } - this.ctx.emit('llm/adapter-change') }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. @@ -137,36 +116,6 @@ export class LlmService extends Service { return this.adapter(options.model).stream(options) }) } - - /** - * Stream one model call as completed content blocks — a convenience view - * for consumers that don't care about token-level deltas. Blocks are - * yielded strictly in stream order as soon as they (and everything before - * them) complete; blocks left open at end of stream (delta-only protocols) - * are assembled and flushed last, so the sequence always equals - * `generate()`'s `message.content`. - */ - async * streamBlocks(options: GenerateOptions): AsyncIterable { - const assembler = new BlockAssembler() - for await (const chunk of this.stream(options)) { - assembler.push(chunk) - yield * assembler.flushReady() - } - yield * assembler.flushRemaining() - } - - /** - * One model call, fully assembled (drains the chunk stream). Dispatches - * through the `llm/generate` waterfall (and the inner stream through - * `llm/stream`). Same completion guarantees as `streamBlocks()`. - */ - generate(options: GenerateOptions): Promise { - return this.ctx.waterfall(this, 'llm/generate', options, async () => { - const assembler = new BlockAssembler() - for await (const chunk of this.stream(options)) assembler.push(chunk) - return assembler.result() - }) - } } export default LlmService diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 863de94b16..63fc0f5b0c 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -193,10 +193,3 @@ export interface GenerateOptions { stop?: string[] signal?: AbortSignal } - -/** Non-streaming result, assembled from the chunk stream. */ -export interface GenerateResult { - message: Message - usage?: TokenUsage - finish: FinishReason -} diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 6ed281add2..e8ad04e3b5 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -81,36 +81,6 @@ describe('BlockAssembler', () => { expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated') }) - it('assembles open blocks at end of stream via flushRemaining', () => { - const assembler = new BlockAssembler() - assembler.push({ type: 'text-delta', index: 0, text: 'open' }) - assembler.push({ type: 'reasoning-delta', index: 1, text: 'thinking' }) - - // flushReady returns nothing because index 0 is incomplete and blocking - const ready = assembler.flushReady() - expect(ready).toEqual([]) - - // flushRemaining assembles everything still open - const remaining = assembler.flushRemaining() - expect(remaining).toEqual([ - { type: 'text', text: 'open' }, - { type: 'reasoning', text: 'thinking' }, - ]) - - // blocks() now matches the flushed view - expect(assembler.blocks()).toEqual(remaining) - }) - - it('result() omits usage key when no usage was received', () => { - const assembler = new BlockAssembler() - assembler.push({ type: 'text-delta', index: 0, text: 'msg' }) - const result = assembler.result() - expect(result.message).toBeDefined() - expect(result.finish).toEqual({ kind: 'stop' }) - // usage should NOT be present on the object at all - expect('usage' in result).toBe(false) - }) - it('ignores duplicate block-start for the same index', () => { const assembler = new BlockAssembler() assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) @@ -142,13 +112,11 @@ describe('BlockAssembler', () => { ]) }) - it('includes usage in result() when usage was received', () => { + it('exposes usage via the getter when a usage chunk was received', () => { const assembler = new BlockAssembler() assembler.push({ type: 'text-delta', index: 0, text: 'msg' }) assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } }) - const result = assembler.result() - expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 3 }) - expect('usage' in result).toBe(true) + expect(assembler.usage).toEqual({ inputTokens: 5, outputTokens: 3 }) }) }) @@ -172,25 +140,25 @@ describe('BlockAssembler regressions (property-test findings)', () => { // Found by fast-check (the property-testing RFC): two block-ends at the same index made the // streamed prefix (first block) disagree with final blocks() (second // block). The first close must win — same straggler rule as post-close - // deltas — so streaming and one-shot assembly stay identical. + // deltas — so the prefix returned incrementally by push() and the final + // blocks() stay identical. const chunks: StreamChunk[] = [ { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, ] const streaming = new BlockAssembler() - const flushed = [] + const closed = [] for (const chunk of chunks) { - streaming.push(chunk) - flushed.push(...streaming.flushReady()) + const block = streaming.push(chunk) + if (block) closed.push(block) } - flushed.push(...streaming.flushRemaining()) const oneShot = new BlockAssembler() for (const chunk of chunks) oneShot.push(chunk) - expect(flushed).toEqual([{ type: 'reasoning', text: 'first' }]) + expect(closed).toEqual([{ type: 'reasoning', text: 'first' }]) expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(flushed).toEqual(oneShot.blocks()) + expect(closed).toEqual(oneShot.blocks()) }) it('push returns undefined for a duplicate block-end (it closed nothing)', () => { diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index 13c7bbd8c2..89c398793d 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' // A small pool of indices so collisions (duplicate-index bugs) are common. @@ -55,38 +55,6 @@ function feed(chunks: StreamChunk[]): BlockAssembler { } describe('BlockAssembler properties', () => { - it('flushReady() ++ flushRemaining() === blocks(), in order', () => { - fc.assert(fc.property(streamArb, (chunks) => { - const streaming = new BlockAssembler() - const flushed: ContentBlock[] = [] - for (const chunk of chunks) { - streaming.push(chunk) - flushed.push(...streaming.flushReady()) - } - flushed.push(...streaming.flushRemaining()) - - const oneShot = feed(chunks).blocks() - expect(flushed).toEqual(oneShot) - })) - }) - - it('streamBlocks-style flush never yields a block before an earlier open one', () => { - // flushReady is strict-order: once it stops at an open index, no later - // index may be emitted until that one closes. We assert the flushed prefix - // is always a prefix of the final blocks() order. - fc.assert(fc.property(streamArb, (chunks) => { - const streaming = new BlockAssembler() - const flushed: ContentBlock[] = [] - for (const chunk of chunks) { - streaming.push(chunk) - flushed.push(...streaming.flushReady()) - } - const finalSoFar = streaming.blocks() - // Everything flushed mid-stream is a prefix of the full ordered blocks. - expect(finalSoFar.slice(0, flushed.length)).toEqual(flushed) - })) - }) - it('partials map size never exceeds the number of distinct indices seen', () => { fc.assert(fc.property(streamArb, (chunks) => { const distinct = new Set() @@ -134,13 +102,9 @@ describe('BlockAssembler properties', () => { it('streaming and one-shot assembly agree on usage and finish', () => { fc.assert(fc.property(streamArb, (chunks) => { - // Streaming consumer: push + flush as it goes. + // Streaming consumer: push as it goes. const streaming = new BlockAssembler() - for (const chunk of chunks) { - streaming.push(chunk) - streaming.flushReady() - } - streaming.flushRemaining() + for (const chunk of chunks) streaming.push(chunk) // One-shot consumer: push all, then read. const oneShot = feed(chunks) expect(streaming.usage).toEqual(oneShot.usage) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 35333c0ffd..f669069c44 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -19,24 +19,22 @@ const SCRIPT: StreamChunk[] = [ ] describe('LlmService', () => { - it('routes stream() to the registered adapter and generate() assembles it', async () => { + it('routes stream() to the registered adapter', async () => { const ctx = new Context() await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) const chunks: StreamChunk[] = [] for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk) - expect(chunks).toHaveLength(3) - - const result = await ctx.llm.generate({ model: 'test-model', messages: [] }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) - expect(result.finish).toEqual({ kind: 'stop' }) + expect(chunks).toEqual(SCRIPT) }) it('throws NO_ADAPTER for unregistered models', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.llm.generate({ model: 'nope', messages: [] })).rejects.toThrow('no adapter registered') + await expect((async () => { + for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ } + })()).rejects.toThrow('no adapter registered') }) it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { @@ -71,21 +69,6 @@ describe('LlmService', () => { expect(chunks[0]).toMatchObject({ index: 99 }) }) - it('lets llm/generate waterfall listeners intercept and transform the result', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) - - ctx.on('llm/generate', async function (_options, next) { - const result = await next() - return { ...result, finish: { kind: 'max-tokens' } as const } - }) - - const result = await ctx.llm.generate({ model: 'test-model', messages: [] }) - expect(result.finish).toEqual({ kind: 'max-tokens' }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) - }) - it('creates LlmError with a code for programmatic handling', () => { const err = new LlmError('something went wrong', 'CUSTOM_CODE') expect(err).toBeInstanceOf(Error) @@ -116,20 +99,13 @@ describe('LlmService', () => { expect(isHarnessError('nope')).toBe(false) }) - it('disposes adapter registration on adapter-change event emission', async () => { + it('removes the adapter when the returned disposer is called', async () => { const ctx = new Context() await ctx.plugin(LlmService) - const changes: string[][] = [] - ctx.on('llm/adapter-change', () => { - changes.push([...ctx.llm.models()]) - }) - const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) - expect(changes).toEqual([['m1']]) - + expect(ctx.llm.models()).toEqual(['m1']) dispose() - expect(changes).toEqual([['m1'], []]) expect(ctx.llm.models()).toEqual([]) }) @@ -147,25 +123,19 @@ describe('LlmService', () => { } }) - it('rolls back the adapter entry when an adapter-change listener throws (P1-1)', async () => { + it('re-registers a model after its prior registration is disposed', async () => { const ctx = new Context() await ctx.plugin(LlmService) - // A change listener that throws on the FIRST emit only. - let threw = false - ctx.on('llm/adapter-change', () => { - if (!threw) { threw = true; throw new Error('boom change listener') } - }) - - // The throwing emit must roll the mutation back, not leak it. - expect(() => ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))).toThrow('boom change listener') - expect(ctx.llm.models()).toEqual([]) // entry rolled back, not leaked - - // A subsequent listener-free register of the SAME model succeeds and - // contributes exactly once (the duplicate check is not wedged). const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) expect(ctx.llm.models()).toEqual(['m1']) dispose() expect(ctx.llm.models()).toEqual([]) + + // The duplicate check is not wedged: the same model registers cleanly again. + const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) + expect(ctx.llm.models()).toEqual(['m1']) + disposeAgain() + expect(ctx.llm.models()).toEqual([]) }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2a497289f4..975b09aa54 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -7,7 +7,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateResult", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, From 584349f881336675ec4518a07272c0c458dc6c09 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:41:02 +0800 Subject: [PATCH 59/87] fix review findings: stale service prose + catalog cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the PR1 diff surfaced docs/cleanup drift: - LlmService class JSDoc still advertised "streaming / non-streaming call surfaces, both interceptable via waterfall events" — corrected to the single streaming surface; regenerated the cordis catalog so its mirror updates. - Removed GenerateResult from gen-cordis-catalog.ts LINK_MAP (the type is gone). - The adapter-change RFC's acceptance criterion named the retired verify-event-taxonomy gate; updated to verify-cordis-catalog. - Dropped the now-tautological "streaming and one-shot assembly agree" property test (the streaming/one-shot distinction lived in the removed flush API; usage/finish remain covered by assembler.spec.ts and the finish property). --- docs/cordis-catalog/events-and-services.md | 2 +- ...-20-drop-unconsumed-llm-adapter-change-event.md | 2 +- packages/llm/llm/src/index.ts | 4 ++-- packages/llm/llm/tests/properties.spec.ts | 14 +------------- scripts/gen-cordis-catalog.ts | 1 - 5 files changed, 5 insertions(+), 18 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index f651867c89..231c7adc83 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -341,7 +341,7 @@ Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/inde ### `ctx.llm` — `LlmService` -The abstract `llm` service: an adapter registry plus streaming / non-streaming call surfaces, both interceptable via waterfall events. +The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. ```ts cordis-catalog registerAdapter(models: string[], adapter: LlmAdapter): () => void diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index 5ed2f0da68..e35e090dbf 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -29,7 +29,7 @@ If an LLM adapter browser or dynamic model-picker needs this signal later, reint ## Acceptance criteria -- `llm/adapter-change` and its emits are gone; `pnpm run verify-event-taxonomy` passes against the updated table. +- `llm/adapter-change` and its emits are gone; `pnpm run verify-cordis-catalog` passes against the regenerated catalog. - HMR-safety tests still pass: disposing a contributing fiber still removes the adapter. - `tools/change` and `system-prompt/change` remain documented and tested. - `pnpm run test:coverage` stays 100% per-file. diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index e463e631d6..320838a8a6 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -63,8 +63,8 @@ export abstract class LlmAdapter { } /** - * The abstract `llm` service: an adapter registry plus streaming / - * non-streaming call surfaces, both interceptable via waterfall events. + * The abstract `llm` service: an adapter registry plus a streaming model-call + * surface, interceptable via the `llm/stream` waterfall. */ export class LlmService extends Service { private adapters = new Map() diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index 89c398793d..c63d56abbb 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -4,7 +4,7 @@ * The assembler is protocol-shaped: arbitrary interleavings of block-start, * deltas, block-end, usage, and finish — valid and malformed (duplicate * indices, stragglers after block-end, missing block-start, delta-only). The - * invariants below are the contract the agent loop and LlmService rely on. + * invariants below are the contract the agent loop relies on. */ import { describe, expect, it } from 'vitest' @@ -99,16 +99,4 @@ describe('BlockAssembler properties', () => { } })) }) - - it('streaming and one-shot assembly agree on usage and finish', () => { - fc.assert(fc.property(streamArb, (chunks) => { - // Streaming consumer: push as it goes. - const streaming = new BlockAssembler() - for (const chunk of chunks) streaming.push(chunk) - // One-shot consumer: push all, then read. - const oneShot = feed(chunks) - expect(streaming.usage).toEqual(oneShot.usage) - expect(streaming.finish).toEqual(oneShot.finish) - })) - }) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fa6545daef..7479f5306c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,7 +64,6 @@ const LINK_MAP: Record = { Message: 'core.md', MessageSource: 'core.md', GenerateOptions: 'core.md', - GenerateResult: 'core.md', SessionEvent: 'core.md', StreamChunk: 'llm-streaming.md', TurnEndReason: 'session.md', From 7792347c4f2f65afe1136ea1dc3eb852c59c2786 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:17:27 +0800 Subject: [PATCH 60/87] simplify(seams): prune dead methods from the persistence and bash seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two capability seams carried abstract methods no production consumer calls. A method no consumer programs against is not a seam — it is speculative surface every implementation must still provide and test. - SessionPersistence: remove has() and delete(), the coordinator's has/delete/deleteCore, and the PersistenceBackend.deleteStored hook (with its jsonl + sqlite + in-spec memory-stub impls). Surviving service surface: create/append/load/list. Production uses only load() (resume) and list() (ACP session/list). - BashExecutor: remove get(id) and list(), the abstract decls and the LocalBashExecutor impls. The internal tasks map survives (it backs ownerOf/readOutput/kill); get/list were pure public accessors over it with no shipping caller and no bash_list tool. - Migrate tests that reached through ctx.bash.get(id) to the public completion seam: a doneFor(id) helper over onTaskDone awaits a task by id, and the HMR-reload ownership test now proves task survival through A's own bash_output ([status: running]) plus ownerOf + B-rejection — a stronger through-the-tool assertion than the removed lookup peek. - Update seam READMEs (six -> four service methods, drop the deleteStored hook and the get/list row) and the two implemented persistence RFCs in place. Implements docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md --- docs/cordis-catalog/events-and-services.md | 4 -- docs/rfc/README.md | 2 +- .../2026-06-14-session-persistence.md | 2 +- ...18-shared-persistence-write-coordinator.md | 12 ++--- .../2026-06-20-prune-dead-seam-methods.md | 2 +- packages/bash/bash-local/src/index.ts | 8 --- .../bash/bash-local/tests/executor.spec.ts | 6 +-- packages/bash/bash/README.md | 1 - packages/bash/bash/src/index.ts | 6 --- packages/bash/bash/tests/service.spec.ts | 10 ---- .../bash/tool-bash/tests/integration.spec.ts | 12 +++-- packages/bash/tool-bash/tests/tools.spec.ts | 54 +++++++++++-------- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 22 ++------ .../tests/jsonl.spec.ts | 42 ++++++--------- .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 18 +------ .../session-persistence-sqlite/src/schema.ts | 2 +- .../tests/sqlite.spec.ts | 4 +- .../session-persistence/README.md | 7 ++- .../session-persistence/src/coordinator.ts | 43 +++------------ .../session-persistence/src/index.ts | 8 +-- .../session-persistence/tests/contract.ts | 20 +------ .../tests/coordinator-contract.ts | 13 +---- .../tests/persistence.spec.ts | 12 ----- 25 files changed, 92 insertions(+), 224 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-06-20-prune-dead-seam-methods.md (99%) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 231c7adc83..e8445b4786 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -327,9 +327,7 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask -abstract get(id: string): BashTask | undefined abstract ownerOf(id: string): string | undefined -abstract list(): BashTask[] abstract readOutput(id: string): BashTaskRead abstract kill(id: string): boolean onTaskDone(listener: BashTaskListener): () => void @@ -369,8 +367,6 @@ abstract create(meta: SessionHeader): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> abstract list(): Promise -abstract has(id: SessionId): Promise -abstract delete(id: SessionId): Promise ``` Types: [SessionEvent](../core-data-structures/core.md) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 21036c5769..be22e0c84b 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 | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Keep one public stop primitive](proposed/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Prune dead methods from the persistence and bash seams](proposed/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | ### Architecture @@ -96,6 +95,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | +| [Prune dead methods from the persistence and bash seams](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 9bcd1f8c6f..a5cbd3bd61 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -16,7 +16,7 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: -1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`/`has`/`delete`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. +1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**). Key choices recorded here because they are durable, contested, and surprising: diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 59ee9bb7c9..44ae67f872 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -4,24 +4,24 @@ Status: implemented (proposed and accepted 2026-06-18, implemented 2026-06-20) ## Problem -`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load`/`has`/`delete` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed. +`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed. ## Decision -Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its six public service methods (`create`/`append`/`load`/`list`/`has`/`delete`) to it. +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its four public service methods (`create`/`append`/`load`/`list`) to it. Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The RFC's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all. ### The hook interface (`PersistenceBackend`) -Seven methods (six required + an optional lifecycle hook) — the only seam between the coordinator and storage: +Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe and `has`. +- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe. - `loadLive(id, cwd)` — read a stored prefix SCOPED to `cwd`. **Deliberately distinct from `loadStored`**: HMR live-adoption must only adopt a persisted log at the SAME cwd as the live session; a same-id log at a different cwd is a collision, not a resume. Collapsing the two reintroduces a cross-cwd adoption bug. SQLite ignores `cwd`. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). -- `deleteStored(id)` / `list()` — remove a stored artifact / list all stored metadata. +- `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. ### The opaque torn marker @@ -34,4 +34,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Risks and what we gave up -The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (seven methods, no inheritance). The hook surface was deliberately held to the minimum: `has` and the create-collision probe are NOT separate hooks — they fold into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements ~7 small primitives instead of copying the entire `session/event` → buffer → flush machinery. +The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (six methods, no inheritance). The hook surface was deliberately held to the minimum: the create-collision probe is NOT a separate hook — it folds into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements a handful of small primitives instead of copying the entire `session/event` → buffer → flush machinery. diff --git a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md similarity index 99% rename from docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md rename to docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 117fe9c72b..ec9dc45223 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -1,6 +1,6 @@ # RFC: Prune dead methods from the persistence and bash capability seams -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index df6e2285a9..7b55200cf8 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -176,20 +176,12 @@ export class LocalBashExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { - return this.tasks.get(id) - } - ownerOf(id: string): string | undefined { // Unknown id and known-but-ownerless both read as undefined — the consumer // treats undefined as "open" and a truly unknown id fails at readOutput/kill. return this.tasks.get(id)?.owner } - list(): BashTask[] { - return [...this.tasks.values()] - } - readOutput(id: string): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 3dd7f7983a..ec90aeb77e 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -98,8 +98,6 @@ describe('LocalBashExecutor background tasks', () => { const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' })) expect(Date.now() - before).toBeLessThan(150) expect(task.status).toBe('running') - expect(bash.get(task.id)).toBe(task) - expect(bash.list()).toContain(task) await task.done expect(task.status).toBe('completed') expect(task.exitCode).toBe(0) @@ -237,7 +235,6 @@ describe('LocalBashExecutor background tasks', () => { await running.done expect(finished.status).toBe('completed') expect(running.signal).toBe('SIGTERM') - expect(bash.list()).toEqual([]) }) it('disposing the executor fiber kills running tasks (no orphans)', async () => { @@ -249,14 +246,13 @@ describe('LocalBashExecutor background tasks', () => { bash.onTaskDone(listener) const task = bash.start(bash.resolve({ command: 'sleep 60' })) - const running = bash.get(task.id)! + const running = task await new Promise(resolve => setTimeout(resolve, 50)) // Grab the pid before dispose clears the registry. const pid = (running as unknown as { running: { pid: number } }).running.pid await fiber.dispose() await waitGone(pid) - expect(bash.list()).toEqual([]) // Listener silenced by base-class teardown — no late notifications. expect(listener).not.toHaveBeenCalled() }) diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ce8816dee7..c982a217c7 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -18,7 +18,6 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su |---|---| | `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. | | `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). | -| `get(id)` / `list()` | Task lookup. | | `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. | | `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. | | `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index f4e2d964fe..9df8c720aa 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -85,9 +85,6 @@ export abstract class BashExecutor extends Service { /** Start a background task and return its handle immediately. */ abstract start(spec: BashExecSpec): BashTask - /** Look up a background task by id. */ - abstract get(id: string): BashTask | undefined - /** * The opaque OWNER token recorded for a background task at {@link start} * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id @@ -103,9 +100,6 @@ export abstract class BashExecutor extends Service { */ abstract ownerOf(id: string): string | undefined - /** All tracked background tasks (insertion order). */ - abstract list(): BashTask[] - /** Read output produced since the previous read. Throws for unknown ids. */ abstract readOutput(id: string): BashTaskRead diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 4b28bb72be..2646715df9 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -44,18 +44,10 @@ class StubExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { - return this.tasks.get(id) - } - ownerOf(id: string): string | undefined { return this.owners.get(id) } - list(): BashTask[] { - return [...this.tasks.values()] - } - readOutput(id: string): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) @@ -88,8 +80,6 @@ describe('BashExecutor service seam', () => { it('registers as ctx.bash and serves the abstract API', async () => { const { bash } = await setup() const task = bash.start(bash.resolve({ command: 'sleep 1' })) - expect(bash.get(task.id)).toBe(task) - expect(bash.list()).toEqual([task]) expect(bash.kill(task.id)).toBe(true) expect(bash.kill(task.id)).toBe(false) const result = await bash.run(bash.resolve({ command: 'true' })) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 0ab786ca85..fadcbf741d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -8,6 +8,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import type { BashTask } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -143,13 +144,18 @@ describe('bash tool through the agent loop', () => { return next() }) + // Capture the single background task's completion. Registered BEFORE send so + // a fast task (echo) can't finish before the listener is attached; onTaskDone + // delivers the task object once it completes (completion may race turn end). + const taskDone = new Promise((resolve) => { + const dispose = ctx.bash.onTaskDone((task) => { dispose(); resolve(task) }) + }) + agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) // Wait for the background task itself (completion may race turn end). - const task = ctx.bash.get(taskId) - if (!task) throw new Error(`task ${taskId} not registered`) - await task.done + await taskDone const log = events(agent) const firstResult = findEvent(log, 'tool/result') diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c49410a9b2..d8d166ea5b 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -66,6 +66,23 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(block => block.type === 'text').map(block => block.text).join('') } +/** + * Resolve once the background task with `id` completes. The task is started + * indirectly (via `ctx.tools.execute`), so `start()`'s return is not accessible + * here; the executor's `onTaskDone` listener delivers the SAME task object on + * completion, which is the surviving seam for awaiting a task by id. + */ +function doneFor(ctx: Context, id: string): Promise { + return new Promise((resolve) => { + const dispose = ctx.bash.onTaskDone((task) => { + if (task.id === id) { + dispose() + resolve(task) + } + }) + }) +} + class LossyReadBashExecutor extends BashExecutor { private readonly task: BashTask = { id: 'bash-lossy', @@ -94,18 +111,10 @@ class LossyReadBashExecutor extends BashExecutor { return this.task } - get(id: string): BashTask | undefined { - return id === this.task.id ? this.task : undefined - } - ownerOf(): string | undefined { return undefined } - list(): BashTask[] { - return [this.task] - } - readOutput(id: string): BashTaskRead { if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`) return { task: this.task, delta: 'tail', lossy: true } @@ -286,7 +295,7 @@ describe('background tools', () => { expect(text(first)).toContain('first') expect(text(first)).toContain('[status: running]') - await ctx.bash.get(id)!.done + await doneFor(ctx, id) const second = await call(ctx, 'bash_output', { task_id: id }) expect(text(second)).toContain('second') expect(text(second)).not.toContain('first') @@ -306,7 +315,7 @@ describe('background tools', () => { 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 }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await ctx.bash.get(id)!.done + await doneFor(ctx, id) const read = await call(ctx, 'bash_output', { task_id: id }) expect(text(read)).toContain('[some output was dropped from memory; full output: ') }) @@ -329,7 +338,7 @@ describe('background tools', () => { const killed = await call(ctx, 'bash_kill', { task_id: id }) expect(text(killed)).toBe(`killed background task ${id}`) - await ctx.bash.get(id)!.done + await doneFor(ctx, id) const again = await call(ctx, 'bash_kill', { task_id: id }) expect(text(again)).toBe(`task ${id} had already finished`) @@ -373,7 +382,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await ctx.bash.get(id)!.done + await doneFor(ctx, id) expect(inject).toHaveBeenCalledTimes(1) const [content, options] = inject.mock.calls[0] as [ @@ -396,7 +405,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() + await expect(doneFor(ctx, id)).resolves.toBeDefined() }) it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => { @@ -415,7 +424,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await ctx.bash.get(id)!.done + await doneFor(ctx, id) // notifyTaskDone caught and logged the rethrown error. expect(errorSpy).toHaveBeenCalled() const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug') @@ -443,7 +452,7 @@ describe('background tools', () => { const id = /task (bash-\d+)/.exec(text(started))![1]! // Unregister the agent BEFORE the task completes (simulate disconnect). unregisterFakeAgents(ctx) - await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() + await expect(doneFor(ctx, id)).resolves.toBeDefined() expect(inject).not.toHaveBeenCalled() }) @@ -451,7 +460,7 @@ describe('background tools', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() + await expect(doneFor(ctx, id)).resolves.toBeDefined() }) }) @@ -534,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => { const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await ctx.bash.get(id)!.done + await doneFor(ctx, id) // Completion does NOT clear ownership: B is still rejected, A still allowed. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) expect(readByB.isError).toBe(true) @@ -567,7 +576,9 @@ describe('background task ownership (cross-session isolation)', () => { // token) survive. await fiber.dispose() await ctx.plugin(ToolBash) - expect(ctx.bash.get(id)?.status).toBe('running') + // The task survived the reload, still running and still owned by A — proven + // via A's own bash_output (reports running status) and the surviving owner token. + expect(text(await callAs(ctx, a, 'bash_output', { task_id: id }))).toContain('[status: running]') expect(ctx.bash.ownerOf(id)).toBe('sess-a') // After reload, ownership is INTACT → B is STILL rejected. @@ -675,10 +686,10 @@ describe('status lines', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - const task = ctx.bash.get(id)! + const done = doneFor(ctx, id) await call(ctx, 'bash_kill', { task_id: id }) - await task.done + const task = await done // Simulate the variant where the close event carried no signal. task.signal = null const read = await call(ctx, 'bash_output', { task_id: id }) @@ -689,8 +700,7 @@ describe('status lines', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - const task = ctx.bash.get(id)! - await task.done + const task = await doneFor(ctx, id) // Defensive: completed tasks always carry an exit code in practice; the // ?? 0 fallback covers task shapes from other executor implementations. task.exitCode = null diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 28a64c4c11..54514755a3 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -21,7 +21,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics -- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. +- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 76df3f3ccb..6de7eafeb3 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -11,7 +11,7 @@ * (the `session/event` → buffer → `session/flush` drain, per-session * serialization, write cursors, fork-seed persistence, HMR live-adoption, * crash-repair sequencing, dispose quiescence) lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The six public + * {@link PersistenceCoordinator} this class composes. The four public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-jsonl @@ -101,14 +101,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.load(id) } - has(id: SessionId): Promise { - return this.coordinator.has(id) - } - - delete(id: SessionId): Promise { - return this.coordinator.delete(id) - } - // `list` is BOTH the public service method and the PersistenceBackend hook — // one method, the bucket walk below. The coordinator adds no orchestration for // listing (no per-id serialization, no cursor), so it would just call back into @@ -180,12 +172,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (closers.length > 0) await this.appendLines(meta, closers) } - /** Remove a session's log file (the coordinator clears its in-memory state). */ - async deleteStored(id: SessionId): Promise { - const file = await this.findLog(id) - if (file) await rm(file.path, { force: true }) - } - /** List all stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { const metas: SessionHeader[] = [] @@ -341,9 +327,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** * Find a session's log file by id across ALL cwd buckets — the any-cwd scan - * for `loadStored`/`deleteStored` (resume and removal identify a session by id - * alone). The cwd-scoped lookup (`loadLive`) does NOT use this; it goes - * straight to `logPath(cwd)` so a no-cwd session can't match a real-cwd bucket. + * for `loadStored` (resume identifies a session by id alone). The cwd-scoped + * lookup (`loadLive`) does NOT use this; it goes straight to `logPath(cwd)` so + * a no-cwd session can't match a real-cwd bucket. */ private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { const target = encodeSegment(id) + '.jsonl' diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index d36723f396..8acc578521 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -105,12 +105,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // nothing on disk yet const dir = sessionDir(root, '/work') await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow() - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) + expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) await ctx.sessionPersistence.append(m.id, oneTurnLog()) // now materialized expect((await stat(logPath(root, '/work', m.id))).isFile()).toBe(true) - expect(await ctx.sessionPersistence.has(m.id)).toBe(true) + expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) void dir }) @@ -448,19 +448,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toContain('big') }) - it('has() finds a session on disk under an unknown cwd (cross-bucket scan)', async () => { - const m = meta('scan-me', '/somewhere') - await ctx.sessionPersistence.create(m) - await ctx.sessionPersistence.append(m.id, oneTurnLog()) - // A fresh backend with no in-memory state → has() must scan disk buckets. - const ctx2 = new Context() - await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) - expect(await ctx2.sessionPersistence.has(m.id)).toBe(true) - expect(await ctx2.sessionPersistence.has(SessionId('absent'))).toBe(false) - await ctx2.fiber.dispose() - }) - it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { // Session A materializes a log under id "reuse". const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { @@ -579,20 +566,23 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.fiber.dispose() }) - it('exists() surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => { - // Same contract on the existence path: a non-ENOENT error from the per-id - // open() must surface, not be collapsed to "not found" (which would let a - // collision check proceed under a false absence assumption). A LAZY session - // (created, never appended) keeps its cwd in state, so has() reaches - // loadLive(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a - // regular file: open()ing `bucket/.jsonl` under it then fails ENOTDIR. + it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => { + // A non-ENOENT error from the per-id open() must surface, not be collapsed to + // "not found" (which would let live-adoption proceed under a false absence + // assumption). A live session's onCreated reaches loadLive(id, cwd) → + // exists(logPath). Make that cwd's bucket DIRECTORY a regular file: open()ing + // `bucket/.jsonl` under it then fails ENOTDIR. const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) - await ctx2.sessionPersistence.create(meta('exists-fault', cwd)) // lazy: no bucket yet await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE - await expect(ctx2.sessionPersistence.has(SessionId('exists-fault'))).rejects.toThrow(/ENOTDIR/) + const backend = ctx2.sessionPersistence as unknown as { inits: Map> } + let s!: Session + await ctx2.plugin(Object.assign((inner: Context) => { + s = inner.sessions.create('exists-fault', { meta: { cwd } }) + }, { inject: ['sessions'] })) + await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() }) @@ -687,7 +677,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { circ.self = circ await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/) // The session was never materialized by any of the rejected appends. - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) + expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) }) it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => { @@ -695,7 +685,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.create(m) const ev = [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] } } }] as unknown as SessionEvent[] await ctx.sessionPersistence.append(m.id, ev) - expect(await ctx.sessionPersistence.has(m.id)).toBe(true) + expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 23916f2bfe..02255c024f 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -13,8 +13,8 @@ The repo targets Node ≥ 24 (the root `engines` field), which includes the stab ## Contract semantics over rows - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) -- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `has()`/`list()` (which report exactly the sessions that have a row). -- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `has()`/`list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. +- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). +- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 49cf3882d4..cef61cb071 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -11,7 +11,7 @@ * Like the JSONL backend it supplies ONLY the storage primitives (the * {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside * transactions); all the write-path orchestration lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The six public + * {@link PersistenceCoordinator} this class composes. The four public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-sqlite @@ -99,14 +99,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.load(id) } - has(id: SessionId): Promise { - return this.coordinator.has(id) - } - - delete(id: SessionId): Promise { - return this.coordinator.delete(id) - } - // `list` is BOTH the public service method and the PersistenceBackend hook — // one method (the SELECT below). The coordinator adds no orchestration for // listing, so routing it through the coordinator would just recurse. Defined @@ -203,12 +195,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } } - /** Remove a session's row (ON DELETE CASCADE drops its events). */ - async deleteStored(id: SessionId): Promise { - await this.ready - this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id) - } - /** List all materialized sessions' metadata (every row is a materialized session). */ async list(): Promise { await this.ready @@ -234,7 +220,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers /** * Insert-or-replace a session's metadata row. The only caller is the first * materializing `appendBatch`, so writing the row IS the materialization (its - * existence is the signal `has`/`list` read). + * existence is the signal `list` reads). */ private writeRow(meta: SessionHeader): void { this.db.prepare(` diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index b6e05a0a3f..8238cba30c 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -21,7 +21,7 @@ export const SCHEMA_VERSION = 2 * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). * The row's EXISTENCE is the materialization signal: it is written only by the * first `append` (lazy materialization), so a created-but-never-appended - * session has no row and is absent from `has`/`list`, mirroring the JSONL + * session has no row and is absent from `list`, mirroring the JSONL * backend's "no file until first append". */ export interface SessionRow { 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 aecfa5665d..262a085ce2 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -214,17 +214,15 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } } }, ]) - expect(await b1.ctx.sessionPersistence.has(m.id)).toBe(true) // materialized await b1.dispose() // A fresh backend loads it: the interrupted (only) turn's real events are // preserved and closed with a synthetic turn/end {interrupted} — NOT - // truncated. The session was materialized, so has()/list() report it present. + // truncated. The session was materialized, so list() reports it present. const b2 = await backend(path) const loaded = await b2.ctx.sessionPersistence.load(m.id) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end']) expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } }) - expect(await b2.ctx.sessionPersistence.has(m.id)).toBe(true) expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id) await b2.dispose() }) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index b21a01b763..928e7b033d 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -11,8 +11,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | -| `list(): Promise` | Lightweight listing from metadata, no full-log parse. | -| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | +| `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | ## Invariants every backend must honor @@ -25,7 +24,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). The `PersistenceBackend` hooks (the only seam between the coordinator and storage): @@ -36,7 +35,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | -| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. | +| `list()` | List all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index ad21f3a8ca..d2f5712502 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -14,7 +14,7 @@ * {@link PersistenceBackend} hook object. * * The abstract {@link SessionPersistence} service's public API is independent of - * this: a backend IS a `SessionPersistence` (its six public methods delegate to + * this: a backend IS a `SessionPersistence` (its four public methods delegate to * a coordinator it composes), so a third-party backend MAY implement the service * directly without using the coordinator at all. * @@ -95,9 +95,6 @@ export interface PersistenceBackend { */ commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise - /** Remove the stored artifact for `id` (the coordinator clears in-memory state). */ - deleteStored(id: SessionId): Promise - /** List all stored (materialized) sessions' metadata. */ list(): Promise @@ -119,13 +116,12 @@ interface SessionState { * SQLite row exists). `create()` registers state LAZILY — cursor 0, * materialized false, nothing on disk — so an empty session leaves no * artifact and the FIRST `appendBatch` writes the header + its events in ONE - * transaction (the "a row exists ⇔ it has events" invariant `has`/`list` - * rely on; a separate up-front materialize could crash leaving a row with + * transaction (the "a row exists ⇔ it has events" invariant `list` + * relies on; a separate up-front materialize could crash leaving a row with * zero events). The flag is the only signal that distinguishes a session - * registered-but-never-written from one durably present, which two callers - * need: `has()` (lazy-but-unwritten is not yet durable) and the reclaim path - * (an abandoned id with no artifact AND no buffered events is free to reuse; - * a materialized one is a real collision). + * registered-but-never-written from one durably present, which the reclaim + * path needs (an abandoned id with no artifact AND no buffered events is free + * to reuse; a materialized one is a real collision). */ materialized: boolean /** @@ -150,7 +146,7 @@ async function settledErrors(promises: Iterable>): Promise { // through the coordinator would only forward to that same hook, so the // coordinator stays out of the listing path entirely. - /** Whether a session is durably present (materialized). */ - async has(id: SessionId): Promise { - const state = this.states.get(id) - if (state?.materialized) return true - // A TRACKED lazy session has a known cwd: probe that exact bucket via - // loadLive(id, cwd) — including the no-cwd bucket when its cwd is undefined. - // An UNTRACKED id has a genuinely UNKNOWN cwd, so it must scan ANY scope via - // loadStored — loadLive(id, undefined) would (correctly) look ONLY in the - // no-cwd bucket and miss a materialized session that lives in a real cwd. - const probe = state !== undefined - ? await this.backend.loadLive(id, state.meta.cwd) - : await this.backend.loadStored(id) - return probe !== undefined - } - - /** Remove a session and all its persisted artifacts. */ - delete(id: SessionId): Promise { - return this.serialize(id, () => this.deleteCore(id)) - } - - private async deleteCore(id: SessionId): Promise { - await this.backend.deleteStored(id) - this.states.delete(id) - } - // --- per-id serialization + adoption helpers --- /** diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 8ff9aa8cb2..a9ffd11792 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -103,7 +103,7 @@ export abstract class SessionPersistence extends Service { /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a - * created-but-never-appended session is absent from {@link has}/{@link list} + * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. */ abstract create(meta: SessionHeader): Promise @@ -143,12 +143,6 @@ export abstract class SessionPersistence extends Service { /** Lightweight listing from metadata, without a full-log parse. */ abstract list(): Promise - - /** Whether a session is durably present (materialized). */ - abstract has(id: SessionId): Promise - - /** Remove a session and all its persisted artifacts. */ - abstract delete(id: SessionId): Promise } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 704e0abfb0..aa7c76c84c 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -142,24 +142,22 @@ export function runPersistenceContract(name: string, make: () => Promise { + it('list() excludes a created-but-never-appended (zero-event) session', async () => { const { persistence, dispose } = await make() try { await persistence.create(meta('empty')) - expect(await persistence.has(SessionId('empty'))).toBe(false) expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty')) } finally { await dispose() } }) - it('has()/list() include a session once it has events', async () => { + it('list() includes a session once it has events', async () => { const { persistence, dispose } = await make() try { const m = meta('s2') await persistence.create(m) await persistence.append(m.id, oneTurnLog()) - expect(await persistence.has(m.id)).toBe(true) expect((await persistence.list()).map(x => x.id)).toContain(m.id) } finally { await dispose() @@ -227,19 +225,5 @@ export function runPersistenceContract(name: string, make: () => Promise { - const { persistence, dispose } = await make() - try { - const m = meta('s6') - await persistence.create(m) - await persistence.append(m.id, oneTurnLog()) - expect(await persistence.has(m.id)).toBe(true) - await persistence.delete(m.id) - expect(await persistence.has(m.id)).toBe(false) - } finally { - await dispose() - } - }) }) } diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 9f42eebd10..1d9a1339d1 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -625,7 +625,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const m = meta('empty-batch', WORK) await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, []) - expect(await ctx.sessionPersistence.has(m.id)).toBe(false) + expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) } finally { await fiber.dispose() await fix.cleanup() @@ -643,17 +643,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('delete of a non-existent session is a no-op', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - try { - await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined() - } finally { - await fiber.dispose() - await fix.cleanup() - } - }) - it('create rejects a duplicate id (in memory and on a persisted log)', async () => { const fix = await makeFixture() const first = await freshCtx(fix) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 8b5a437735..4e4cf67822 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -61,14 +61,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.load(id) } - has(id: SessionId): Promise { - return this.coordinator.has(id) - } - - delete(id: SessionId): Promise { - return this.coordinator.delete(id) - } - /** White-box accessor: await a specific session's onCreated init. */ get inits(): Map> { return this.coordinator.inits @@ -114,10 +106,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) } - async deleteStored(id: SessionId): Promise { - this.store.delete(id) - } - async list(): Promise { return [...this.store.values()].map(e => structuredClone(e.meta)) } From 5f9d10c58793de43dcc208d80d04a3fd0bc622ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:45:21 +0800 Subject: [PATCH 61/87] fix review findings: stale seam docs + race-free doneFor test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the PR2 diff caught doc/comment sites doc-sync does not gate (core-data-structures prose) and a latent test-helper race: - docs/core-data-structures/persistence.md + bash.md, sqlite README, and two source comments (coordinator.ts, jsonl.spec.ts) still listed the removed has/delete/get/list methods — updated to the surviving four-method persistence surface and the get/list-free bash seam. - doneFor(ctx, id) attached its onTaskDone listener lazily, after the task could already have closed (e.g. `true`), so it could miss the completion and hang. Replaced with trackCompletions(ctx): one eagerly-installed listener (mounted in setup() before any task starts) records every completion, and doneFor resolves immediately for an already-finished task or on completion otherwise. Race-free, and there is no get-by-id seam left to poll instead. --- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/persistence.md | 4 +- packages/bash/tool-bash/tests/tools.spec.ts | 46 ++++++++++++++----- .../tests/jsonl.spec.ts | 4 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence/src/coordinator.ts | 2 +- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c601d8cd74..dfbdec1d2f 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -120,4 +120,4 @@ interface BashTaskRead { ## The service -`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). +`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `ownerOf`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 630d38480f..f1ee857998 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list/has/delete over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -55,7 +55,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list/has/delete over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data)` maps 1:1 onto the event, so there is no parallel persisted schema to keep in sync. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index d8d166ea5b..b7429ff9bb 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -24,6 +24,7 @@ async function setup() { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } await ctx.plugin(ToolBash) + trackCompletions(ctx) return ctx } @@ -67,22 +68,42 @@ function text(result: { content: { type: string; text?: string }[] }): string { } /** - * Resolve once the background task with `id` completes. The task is started - * indirectly (via `ctx.tools.execute`), so `start()`'s return is not accessible - * here; the executor's `onTaskDone` listener delivers the SAME task object on - * completion, which is the surviving seam for awaiting a task by id. + * Per-context background-completion tracker. The task is started indirectly + * (via `ctx.tools.execute`), so `start()`'s return is not accessible here, and + * there is no get-by-id seam to poll current state — the only surviving way to + * await a task by id is the executor's `onTaskDone` listener. Registering that + * listener lazily (after the task may have already closed) would miss the + * completion and hang; so {@link trackCompletions} installs ONE listener + * EAGERLY (before any task starts) that records every completion, and + * {@link doneFor} resolves from that record — immediately if the task already + * finished, otherwise when it does. Call `trackCompletions(ctx)` right after + * the executor is mounted (`setup()` does this for you). */ -function doneFor(ctx: Context, id: string): Promise { - return new Promise((resolve) => { - const dispose = ctx.bash.onTaskDone((task) => { - if (task.id === id) { - dispose() - resolve(task) - } - }) +const completions = new WeakMap; waiters: Map void> }>() + +function trackCompletions(ctx: Context): void { + const state = { done: new Map(), waiters: new Map void>() } + completions.set(ctx, state) + ctx.bash.onTaskDone((task) => { + const waiter = state.waiters.get(task.id) + if (waiter) { + state.waiters.delete(task.id) + waiter(task) + } else { + state.done.set(task.id, task) + } }) } +/** Resolve (with the task object) once the background task `id` has completed. */ +function doneFor(ctx: Context, id: string): Promise { + const state = completions.get(ctx) + if (!state) throw new Error('trackCompletions(ctx) must be called before doneFor(ctx, …)') + const already = state.done.get(id) + if (already) return Promise.resolve(already) + return new Promise(resolve => state.waiters.set(id, resolve)) +} + class LossyReadBashExecutor extends BashExecutor { private readonly task: BashTask = { id: 'bash-lossy', @@ -312,6 +333,7 @@ describe('background tools', () => { await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } await ctx.plugin(ToolBash) + trackCompletions(ctx) 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 }) const id = /task (bash-\d+)/.exec(text(started))![1]! diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 8acc578521..86c4a9d08b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -629,8 +629,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { const a = meta('dup-id', '/projA') await ctx.sessionPersistence.create(a) await ctx.sessionPersistence.append(a.id, oneTurnLog()) - // A fresh backend creating the SAME id under cwd B must still refuse: load/ - // has identify by id across all buckets, so a second log would make resume + // A fresh backend creating the SAME id under cwd B must still refuse: load + // identifies by id across all buckets, so a second log would make resume // nondeterministic. create scans every bucket, not just meta.cwd's. const ctx2 = new Context() await ctx2.plugin(SessionStore) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 02255c024f..f397f80445 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). 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 (`has`/`list` report exactly the sessions that have a row), so no separate column is needed. +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). 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). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index d2f5712502..58f1246763 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -202,7 +202,7 @@ export class PersistenceCoordinator { throw new Error(`session "${meta.id}" already exists in this backend`) } // A persisted artifact under this id (in ANY scope) blocks creation: load/ - // has/resume identify a session by id alone, so a second artifact would make + // resume identify a session by id alone, so a second artifact would make // resume nondeterministic. if (await this.backend.loadStored(meta.id) !== undefined) { throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) From 6ca8c3b99a672d12b84ebff6443f98d26451f27e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:00:07 +0800 Subject: [PATCH 62/87] fix review findings: stale get/list in proposed RFCs + doneFor double-await Second Codex pass caught two proposed RFCs that describe the bash seam as it WAS (with get/list) and would read stale once this prune lands, plus a latent test-helper edge: - docs/rfc/proposed/architecture/2026-06-20-branded-ids.md and 2026-06-20-generic-long-running-tool-runtime.md: drop get/list from the BashExecutor seam description (surviving: resolve/run/start/ownerOf/ readOutput/kill/onTaskDone). branded-ids will be further updated when it is implemented; this keeps it accurate in the meantime. - trackCompletions now records every completion to `done` unconditionally (and also wakes a parked waiter), so a second doneFor(id) after completion resolves instead of hanging. --- docs/rfc/proposed/architecture/2026-06-20-branded-ids.md | 4 ++-- .../2026-06-20-generic-long-running-tool-runtime.md | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index 93a4bf6cda..d69eb8f8c6 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -6,7 +6,7 @@ Status: proposed The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". @@ -16,7 +16,7 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index 4f034e3020..d17224c46f 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. +The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `ownerOf`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. The [tool cookbook](../../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index b7429ff9bb..e0cc1165e3 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -85,12 +85,13 @@ function trackCompletions(ctx: Context): void { const state = { done: new Map(), waiters: new Map void>() } completions.set(ctx, state) ctx.bash.onTaskDone((task) => { + // Always record the completion so a later doneFor(id) still resolves; also + // wake any waiter already parked on this id. + state.done.set(task.id, task) const waiter = state.waiters.get(task.id) if (waiter) { state.waiters.delete(task.id) waiter(task) - } else { - state.done.set(task.id, task) } }) } From 24168aee70ac77d3dbaaecf0a8224561f7002bfd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:17:11 +0800 Subject: [PATCH 63/87] =?UTF-8?q?revert=20bash=20get()/list()=20removal=20?= =?UTF-8?q?=E2=80=94=20keep=20persistence-only=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original prune removed BashExecutor.get()/.list() too, but each is a one-line accessor over the executor's already-tracked tasks map, and removing them forced dsh-tool-bash's tests onto a ~35-line onTaskDone completion-tracking harness just to replace the one-line ctx.bash.get(id) lookup. Per the AGENTS.md "RFCs are proposals, not golden truth" principle, that disproportionate migration cost is evidence the methods earn their keep — a test harness IS a consumer programming against the seam. Restore get()/list() (seam + LocalBashExecutor impl + the bash tests that used them, dropping the doneFor/trackCompletions scaffolding). The persistence has()/delete()/deleteStored removal stands — it had only contract-test callers and no test-ergonomics cost. The RFC is retitled persistence-only with an implementation note recording the bash revert. --- docs/cordis-catalog/events-and-services.md | 2 + docs/core-data-structures/bash.md | 2 +- .../2026-06-20-prune-dead-seam-methods.md | 12 +-- .../architecture/2026-06-20-branded-ids.md | 4 +- ...06-20-generic-long-running-tool-runtime.md | 2 +- packages/bash/bash-local/src/index.ts | 8 ++ .../bash/bash-local/tests/executor.spec.ts | 6 +- packages/bash/bash/README.md | 1 + packages/bash/bash/src/index.ts | 6 ++ packages/bash/bash/tests/service.spec.ts | 10 +++ .../bash/tool-bash/tests/integration.spec.ts | 12 +-- packages/bash/tool-bash/tests/tools.spec.ts | 77 ++++++------------- 12 files changed, 68 insertions(+), 74 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e8445b4786..0422555ec2 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -327,7 +327,9 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask +abstract get(id: string): BashTask | undefined abstract ownerOf(id: string): string | undefined +abstract list(): BashTask[] abstract readOutput(id: string): BashTaskRead abstract kill(id: string): boolean onTaskDone(listener: BashTaskListener): () => void diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index dfbdec1d2f..c601d8cd74 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -120,4 +120,4 @@ interface BashTaskRead { ## The service -`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `ownerOf`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). +`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)). diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index ec9dc45223..3cf99e47ef 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -1,7 +1,9 @@ -# RFC: Prune dead methods from the persistence and bash capability seams +# RFC: Prune dead methods from the persistence seam Status: implemented (proposed and accepted 2026-06-20) +> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. + ## Problem Two capability seams ([interface / implementation / consumer](../../implemented/architecture/2026-06-13-capability-seams.md)) carry abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. @@ -35,10 +37,10 @@ Re-adding a seam method with a live consumer is cheap and better-designed than t ## Acceptance criteria -- `has`/`delete`/`deleteStored` and `get`/`list` are gone from their seams, impls, and contract suites; `pnpm run knip` reports no new dead exports. -- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`ownerOf`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. -- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed methods are deleted with them). -- Seam READMEs and `docs/architecture.md` no longer list the removed methods. +- `has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites; `pnpm run knip` reports no new dead exports. (The bash `get`/`list` removal was reverted — see the implementation note above; those methods remain.) +- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`get`/`ownerOf`/`list`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. +- `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed persistence methods are deleted with them). +- Persistence seam READMEs and `docs/architecture.md` no longer list the removed `has`/`delete` methods. ## Risks diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index d69eb8f8c6..93a4bf6cda 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -6,7 +6,7 @@ Status: proposed The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". @@ -16,7 +16,7 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index d17224c46f..4f034e3020 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `ownerOf`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. +The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. The [tool cookbook](../../../cookbook/adding-a-tool.md) already points at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. If future tools need background execution, polling, kill, ownership, and completion notices, those semantics should not be hidden in `dsh-bash`. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 7b55200cf8..df6e2285a9 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -176,12 +176,20 @@ export class LocalBashExecutor extends BashExecutor { return task } + get(id: string): BashTask | undefined { + return this.tasks.get(id) + } + ownerOf(id: string): string | undefined { // Unknown id and known-but-ownerless both read as undefined — the consumer // treats undefined as "open" and a truly unknown id fails at readOutput/kill. return this.tasks.get(id)?.owner } + list(): BashTask[] { + return [...this.tasks.values()] + } + readOutput(id: string): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index ec90aeb77e..3dd7f7983a 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -98,6 +98,8 @@ describe('LocalBashExecutor background tasks', () => { const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' })) expect(Date.now() - before).toBeLessThan(150) expect(task.status).toBe('running') + expect(bash.get(task.id)).toBe(task) + expect(bash.list()).toContain(task) await task.done expect(task.status).toBe('completed') expect(task.exitCode).toBe(0) @@ -235,6 +237,7 @@ describe('LocalBashExecutor background tasks', () => { await running.done expect(finished.status).toBe('completed') expect(running.signal).toBe('SIGTERM') + expect(bash.list()).toEqual([]) }) it('disposing the executor fiber kills running tasks (no orphans)', async () => { @@ -246,13 +249,14 @@ describe('LocalBashExecutor background tasks', () => { bash.onTaskDone(listener) const task = bash.start(bash.resolve({ command: 'sleep 60' })) - const running = task + const running = bash.get(task.id)! await new Promise(resolve => setTimeout(resolve, 50)) // Grab the pid before dispose clears the registry. const pid = (running as unknown as { running: { pid: number } }).running.pid await fiber.dispose() await waitGone(pid) + expect(bash.list()).toEqual([]) // Listener silenced by base-class teardown — no late notifications. expect(listener).not.toHaveBeenCalled() }) diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index c982a217c7..ce8816dee7 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -18,6 +18,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su |---|---| | `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. | | `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). | +| `get(id)` / `list()` | Task lookup. | | `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. | | `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. | | `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. | diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 9df8c720aa..f4e2d964fe 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -85,6 +85,9 @@ export abstract class BashExecutor extends Service { /** Start a background task and return its handle immediately. */ abstract start(spec: BashExecSpec): BashTask + /** Look up a background task by id. */ + abstract get(id: string): BashTask | undefined + /** * The opaque OWNER token recorded for a background task at {@link start} * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id @@ -100,6 +103,9 @@ export abstract class BashExecutor extends Service { */ abstract ownerOf(id: string): string | undefined + /** All tracked background tasks (insertion order). */ + abstract list(): BashTask[] + /** Read output produced since the previous read. Throws for unknown ids. */ abstract readOutput(id: string): BashTaskRead diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 2646715df9..4b28bb72be 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -44,10 +44,18 @@ class StubExecutor extends BashExecutor { return task } + get(id: string): BashTask | undefined { + return this.tasks.get(id) + } + ownerOf(id: string): string | undefined { return this.owners.get(id) } + list(): BashTask[] { + return [...this.tasks.values()] + } + readOutput(id: string): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) @@ -80,6 +88,8 @@ describe('BashExecutor service seam', () => { it('registers as ctx.bash and serves the abstract API', async () => { const { bash } = await setup() const task = bash.start(bash.resolve({ command: 'sleep 1' })) + expect(bash.get(task.id)).toBe(task) + expect(bash.list()).toEqual([task]) expect(bash.kill(task.id)).toBe(true) expect(bash.kill(task.id)).toBe(false) const result = await bash.run(bash.resolve({ command: 'true' })) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index fadcbf741d..0ab786ca85 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -8,7 +8,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import type { BashTask } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -144,18 +143,13 @@ describe('bash tool through the agent loop', () => { return next() }) - // Capture the single background task's completion. Registered BEFORE send so - // a fast task (echo) can't finish before the listener is attached; onTaskDone - // delivers the task object once it completes (completion may race turn end). - const taskDone = new Promise((resolve) => { - const dispose = ctx.bash.onTaskDone((task) => { dispose(); resolve(task) }) - }) - agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) // Wait for the background task itself (completion may race turn end). - await taskDone + const task = ctx.bash.get(taskId) + if (!task) throw new Error(`task ${taskId} not registered`) + await task.done const log = events(agent) const firstResult = findEvent(log, 'tool/result') diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index e0cc1165e3..c49410a9b2 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -24,7 +24,6 @@ async function setup() { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } await ctx.plugin(ToolBash) - trackCompletions(ctx) return ctx } @@ -67,44 +66,6 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(block => block.type === 'text').map(block => block.text).join('') } -/** - * Per-context background-completion tracker. The task is started indirectly - * (via `ctx.tools.execute`), so `start()`'s return is not accessible here, and - * there is no get-by-id seam to poll current state — the only surviving way to - * await a task by id is the executor's `onTaskDone` listener. Registering that - * listener lazily (after the task may have already closed) would miss the - * completion and hang; so {@link trackCompletions} installs ONE listener - * EAGERLY (before any task starts) that records every completion, and - * {@link doneFor} resolves from that record — immediately if the task already - * finished, otherwise when it does. Call `trackCompletions(ctx)` right after - * the executor is mounted (`setup()` does this for you). - */ -const completions = new WeakMap; waiters: Map void> }>() - -function trackCompletions(ctx: Context): void { - const state = { done: new Map(), waiters: new Map void>() } - completions.set(ctx, state) - ctx.bash.onTaskDone((task) => { - // Always record the completion so a later doneFor(id) still resolves; also - // wake any waiter already parked on this id. - state.done.set(task.id, task) - const waiter = state.waiters.get(task.id) - if (waiter) { - state.waiters.delete(task.id) - waiter(task) - } - }) -} - -/** Resolve (with the task object) once the background task `id` has completed. */ -function doneFor(ctx: Context, id: string): Promise { - const state = completions.get(ctx) - if (!state) throw new Error('trackCompletions(ctx) must be called before doneFor(ctx, …)') - const already = state.done.get(id) - if (already) return Promise.resolve(already) - return new Promise(resolve => state.waiters.set(id, resolve)) -} - class LossyReadBashExecutor extends BashExecutor { private readonly task: BashTask = { id: 'bash-lossy', @@ -133,10 +94,18 @@ class LossyReadBashExecutor extends BashExecutor { return this.task } + get(id: string): BashTask | undefined { + return id === this.task.id ? this.task : undefined + } + ownerOf(): string | undefined { return undefined } + list(): BashTask[] { + return [this.task] + } + readOutput(id: string): BashTaskRead { if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`) return { task: this.task, delta: 'tail', lossy: true } @@ -317,7 +286,7 @@ describe('background tools', () => { expect(text(first)).toContain('first') expect(text(first)).toContain('[status: running]') - await doneFor(ctx, id) + await ctx.bash.get(id)!.done const second = await call(ctx, 'bash_output', { task_id: id }) expect(text(second)).toContain('second') expect(text(second)).not.toContain('first') @@ -334,11 +303,10 @@ describe('background tools', () => { await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } await ctx.plugin(ToolBash) - trackCompletions(ctx) 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 }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await doneFor(ctx, id) + await ctx.bash.get(id)!.done const read = await call(ctx, 'bash_output', { task_id: id }) expect(text(read)).toContain('[some output was dropped from memory; full output: ') }) @@ -361,7 +329,7 @@ describe('background tools', () => { const killed = await call(ctx, 'bash_kill', { task_id: id }) expect(text(killed)).toBe(`killed background task ${id}`) - await doneFor(ctx, id) + await ctx.bash.get(id)!.done const again = await call(ctx, 'bash_kill', { task_id: id }) expect(text(again)).toBe(`task ${id} had already finished`) @@ -405,7 +373,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await doneFor(ctx, id) + await ctx.bash.get(id)!.done expect(inject).toHaveBeenCalledTimes(1) const [content, options] = inject.mock.calls[0] as [ @@ -428,7 +396,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await expect(doneFor(ctx, id)).resolves.toBeDefined() + await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => { @@ -447,7 +415,7 @@ describe('background tools', () => { agent, }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await doneFor(ctx, id) + await ctx.bash.get(id)!.done // notifyTaskDone caught and logged the rethrown error. expect(errorSpy).toHaveBeenCalled() const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug') @@ -475,7 +443,7 @@ describe('background tools', () => { const id = /task (bash-\d+)/.exec(text(started))![1]! // Unregister the agent BEFORE the task completes (simulate disconnect). unregisterFakeAgents(ctx) - await expect(doneFor(ctx, id)).resolves.toBeDefined() + await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() expect(inject).not.toHaveBeenCalled() }) @@ -483,7 +451,7 @@ describe('background tools', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await expect(doneFor(ctx, id)).resolves.toBeDefined() + await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) }) @@ -566,7 +534,7 @@ describe('background task ownership (cross-session isolation)', () => { const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - await doneFor(ctx, id) + await ctx.bash.get(id)!.done // Completion does NOT clear ownership: B is still rejected, A still allowed. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) expect(readByB.isError).toBe(true) @@ -599,9 +567,7 @@ describe('background task ownership (cross-session isolation)', () => { // token) survive. await fiber.dispose() await ctx.plugin(ToolBash) - // The task survived the reload, still running and still owned by A — proven - // via A's own bash_output (reports running status) and the surviving owner token. - expect(text(await callAs(ctx, a, 'bash_output', { task_id: id }))).toContain('[status: running]') + expect(ctx.bash.get(id)?.status).toBe('running') expect(ctx.bash.ownerOf(id)).toBe('sess-a') // After reload, ownership is INTACT → B is STILL rejected. @@ -709,10 +675,10 @@ describe('status lines', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - const done = doneFor(ctx, id) + const task = ctx.bash.get(id)! await call(ctx, 'bash_kill', { task_id: id }) - const task = await done + await task.done // Simulate the variant where the close event carried no signal. task.signal = null const read = await call(ctx, 'bash_output', { task_id: id }) @@ -723,7 +689,8 @@ describe('status lines', () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) const id = /task (bash-\d+)/.exec(text(started))![1]! - const task = await doneFor(ctx, id) + const task = ctx.bash.get(id)! + await task.done // Defensive: completed tasks always carry an exit code in practice; the // ?? 0 fallback covers task shapes from other executor implementations. task.exitCode = null From d6a2ab30c81d59d303a414cf75ebca15f6357263 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 07:17:25 +0800 Subject: [PATCH 64/87] feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes the two gaps in the "brand ids that cross package boundaries" policy and fixes the dependency direction so a capability package never pulls in an unrelated one. - Extract the `Branded` primitive into a new standalone type-only package `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps. dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session, dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a generic execution backend must not couple to the LLM or session vocabulary). - Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id, the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary that casts SessionId -> OwnerToken. - Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters at the config boundary and the inner create()/resume casts disappear (only the genuinely-new per-run session-id string is cast). - Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store Map keys and public params/exports (SessionStore, AgentRegistry + factory options, the ACP session-id surface + ToolPresenter CallId map, the persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps). - Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point the Branded type-equiv at dsh-brand, fix stale param types in the session/ agent/bash READMEs, regenerate the cordis catalog + module graph. Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md --- docs/cookbook/extension-cookbook.md | 3 +- docs/cordis-catalog/events-and-services.md | 50 +++++++------- docs/core-data-structures/bash.md | 8 ++- docs/core-data-structures/core.md | 6 +- docs/module-graph.md | 13 ++-- docs/rfc/README.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 6 +- .../2026-06-20-prune-dead-seam-methods.md | 2 +- .../coding-agent/tests/coding-task.e2e.ts | 3 +- examples/coding-agent/tests/full-loop.e2e.ts | 3 +- examples/coding-agent/tests/resume.e2e.ts | 8 ++- knip.json | 4 ++ packages/bash/bash-local/src/index.ts | 18 ++--- .../bash/bash-local/tests/executor.spec.ts | 6 +- packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash/README.md | 2 +- packages/bash/bash/package.json | 2 + packages/bash/bash/src/index.ts | 11 ++-- packages/bash/bash/src/types.ts | 31 ++++++++- packages/bash/bash/tests/service.spec.ts | 16 ++--- packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/src/index.ts | 12 ++-- .../bash/tool-bash/tests/integration.spec.ts | 11 ++-- packages/bash/tool-bash/tests/tools.spec.ts | 44 ++++++------- packages/core/agent-loop/src/index.ts | 34 ++++++---- packages/core/agent-loop/tests/agent.spec.ts | 46 ++++++------- packages/core/agent-loop/tests/cancel.spec.ts | 26 ++++---- .../tests/config-session-id.spec.ts | 22 +++---- .../agent-loop/tests/coverage-edges.spec.ts | 22 +++---- packages/core/agent-loop/tests/loop.spec.ts | 60 ++++++++--------- .../core/agent-loop/tests/properties.spec.ts | 8 +-- packages/core/agent-loop/tests/resume.spec.ts | 32 ++++----- .../agent-loop/tests/review-fixes.spec.ts | 66 +++++++++---------- packages/core/agent/README.md | 2 +- packages/core/agent/package.json | 2 + packages/core/agent/src/index.ts | 14 ++-- packages/core/agent/src/types.ts | 3 +- packages/core/agent/tests/agent.spec.ts | 24 +++---- packages/core/agent/tsconfig.json | 3 + packages/core/session/README.md | 4 +- packages/core/session/package.json | 2 + packages/core/session/src/index.ts | 8 +-- packages/core/session/src/types.ts | 3 +- packages/core/session/tests/session.spec.ts | 42 ++++++------ packages/core/session/tsconfig.json | 3 + packages/llm/llm-pi-ai/src/adapter.ts | 7 +- packages/llm/llm-pi-ai/src/convert.ts | 2 +- packages/llm/llm/package.json | 2 + packages/llm/llm/src/brand.ts | 21 ++---- packages/llm/llm/tsconfig.json | 3 + .../tests/jsonl.spec.ts | 22 +++---- .../tests/sqlite.spec.ts | 6 +- .../session-persistence/src/coordinator.ts | 4 +- .../tests/coordinator-contract.ts | 42 ++++++------ packages/support/invariants/src/index.ts | 3 +- .../invariants/tests/invariants.spec.ts | 6 +- packages/support/ui-stdio/src/index.ts | 4 +- packages/ui/acp/src/index.ts | 58 ++++++++-------- packages/ui/acp/tests/bridge.spec.ts | 9 +-- packages/ui/acp/tests/dispose.spec.ts | 55 ++++++++-------- packages/ui/acp/tests/edges.spec.ts | 4 +- packages/ui/acp/tests/load.spec.ts | 9 +-- packages/ui/acp/tests/multi-session.spec.ts | 5 +- packages/ui/acp/tests/properties.spec.ts | 4 +- packages/ui/acp/tests/stream-update.spec.ts | 10 +-- packages/ui/acp/tests/turns.spec.ts | 5 +- packages/util/brand/README.md | 26 ++++++++ packages/util/brand/package.json | 28 ++++++++ packages/util/brand/src/index.ts | 27 ++++++++ packages/util/brand/tsconfig.json | 11 ++++ pnpm-lock.yaml | 18 +++++ scripts/type-equiv.manifest.json | 2 +- tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.typecheck.json | 1 + 75 files changed, 644 insertions(+), 445 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-06-20-branded-ids.md (96%) create mode 100644 packages/util/brand/README.md create mode 100644 packages/util/brand/package.json create mode 100644 packages/util/brand/src/index.ts create mode 100644 packages/util/brand/tsconfig.json diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1739acd32c..1db79f627b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -38,6 +38,7 @@ A UI plugin consumes `agent/stream-chunk` and session events for rendering, and ```ts import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -49,7 +50,7 @@ export function apply(ctx: Context) { ctx.on('agent/stream-chunk', (agent, turn, step, chunk) => { if (chunk.type === 'text-delta') render(chunk.text) }) - onUserInput(text => ctx.agents.get('main')?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 0422555ec2..de5cf6ad72 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:140`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:141`](../../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:146`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:147`](../../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:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:198`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: string, options: AgentOptions = {}): ReactLoopAgent +create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -304,7 +304,7 @@ setFactory(factory: AgentFactory): () => void create(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void -get(id: string): Agent | undefined +get(id: AgentId): Agent | undefined list(): Agent[] ``` @@ -327,17 +327,17 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask -abstract get(id: string): BashTask | undefined -abstract ownerOf(id: string): string | undefined +abstract get(id: BashTaskId): BashTask | undefined +abstract ownerOf(id: BashTaskId): OwnerToken | undefined abstract list(): BashTask[] -abstract readOutput(id: string): BashTaskRead -abstract kill(id: string): boolean +abstract readOutput(id: BashTaskId): BashTaskRead +abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void ``` Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) ### `ctx.llm` — `LlmService` @@ -382,11 +382,11 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog -create(id?: string, options?: CreateSessionOptions): Session -prepare(id?: string, options?: CreateSessionOptions): Session +create(id?: SessionId, options?: CreateSessionOptions): Session +prepare(id?: SessionId, options?: CreateSessionOptions): Session enter(session: Session): () => void announce(session: Session): void -get(id: string): Session | undefined +get(id: SessionId): Session | undefined list(): Session[] ``` diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c601d8cd74..807c7401fc 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -25,7 +25,7 @@ interface BashExecRequest { * seam — that is the consumer's job). Absent for foreground runs and for an * ownerless background start (a non-agent caller). */ - owner?: string | undefined + owner?: OwnerToken | undefined } ``` @@ -44,12 +44,14 @@ interface BashExecSpec { * silently-absent property that yields an unowned (cross-session-readable) * task. `start()` stores it; `run()` (foreground) ignores it. */ - owner: string | undefined + owner: OwnerToken | undefined } ``` The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. +Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. + ## Foreground runs: `BashRunResult` The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. @@ -90,7 +92,7 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT ```ts type-equiv interface BashTask { - readonly id: string + readonly id: BashTaskId readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index a79255a5c7..46b416724e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -61,13 +61,15 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. -Source: [`packages/llm/llm/src/brand.ts`](../../packages/llm/llm/src/brand.ts) +The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm). + +Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) ```ts type-equiv type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. +The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md). ## Content blocks and messages diff --git a/docs/module-graph.md b/docs/module-graph.md index 037c6be8c8..92a2e7a09b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -7,11 +7,15 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid graph TD + bash --> brand + llm --> brand bash-local --> bash llm-deepseek --> llm llm-pi-ai --> llm + session --> brand session --> llm system-prompt --> llm + agent --> brand agent --> llm agent --> session llm-replay --> llm @@ -49,14 +53,15 @@ graph TD | Package | Depends on | | --- | --- | -| `bash` | — | -| `llm` | — | +| `brand` | — | +| `bash` | `brand` | +| `llm` | `brand` | | `bash-local` | `bash` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | -| `session` | `llm` | +| `session` | `brand`, `llm` | | `system-prompt` | `llm` | -| `agent` | `llm`, `session` | +| `agent` | `brand`, `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index be22e0c84b..eaefbdda94 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 | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process @@ -116,6 +115,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md similarity index 96% rename from docs/rfc/proposed/architecture/2026-06-20-branded-ids.md rename to docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 93a4bf6cda..c203e76d42 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -1,6 +1,6 @@ # RFC: Branded IDs everywhere they belong -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem @@ -8,7 +8,7 @@ The harness already brands three identifiers — `CallId` (`packages/llm/llm/src **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. @@ -63,6 +63,6 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Risks / what we give up -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above). +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above). - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 3cf99e47ef..1eec8fd36b 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,7 +2,7 @@ Status: implemented (proposed and accepted 2026-06-20) -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. +> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. ## Problem diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 4684301725..68bca5cdfa 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** @@ -53,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir) - const agent = ctx.agentLoop.create('e2e-task', { + const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT, }) diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 93bc0b1fac..2b70d6f339 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** @@ -20,7 +21,7 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => { it('runs a bash command on request and reports its output', async () => { ctx = await codingHarness(process.cwd()) - const agent = ctx.agentLoop.create('e2e-loop', { + const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT, }) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index cf2d910138..450938fc6d 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -4,6 +4,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' /** @@ -15,7 +17,7 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. */ const SECRET = 'plum-galaxy-1791' -const SESSION_ID = 'resume-e2e-session' +const SESSION_ID = SessionId('resume-e2e-session') let ctx: Context | undefined let root: string | undefined @@ -38,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), root) const first = ctx.agents.create({ - agentId: 'resume-1', + agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, }).agent as ReactLoopAgent @@ -52,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), root) const resumed = (await ctx.agents.resume({ - agentId: 'resume-2', + agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, })).agent as ReactLoopAgent diff --git a/knip.json b/knip.json index e2e82038e3..6699203570 100644 --- a/knip.json +++ b/knip.json @@ -17,6 +17,10 @@ "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/util/brand": { + "project": ["src/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/llm/llm-deepseek": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index df6e2285a9..05f1ed75dd 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -15,8 +15,8 @@ import { Context } from 'cordis' import z from 'schemastery' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' +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 type { RunInternals, RunningBash } from './run.ts' @@ -50,7 +50,7 @@ interface TrackedTask extends BashTask { stdoutOffset: number stderrOffset: number /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */ - owner: string | undefined + owner: OwnerToken | undefined } /** @@ -67,7 +67,7 @@ export class LocalBashExecutor extends BashExecutor { maxOutputBytes: z.number().default(64_000), }) - private tasks = new Map() + private tasks = new Map() private nextTaskId = 1 /** Test seam: timer/spill knobs forwarded to runBash. */ internals: RunInternals = {} @@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor { signal: spec.signal, }, this.internals) - const id = `bash-${this.nextTaskId++}` + const id = BashTaskId(`bash-${this.nextTaskId++}`) const task: TrackedTask = { id, command: spec.command, @@ -176,11 +176,11 @@ export class LocalBashExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return this.tasks.get(id) } - ownerOf(id: string): string | undefined { + ownerOf(id: BashTaskId): OwnerToken | undefined { // Unknown id and known-but-ownerless both read as undefined — the consumer // treats undefined as "open" and a truly unknown id fails at readOutput/kill. return this.tasks.get(id)?.owner @@ -190,7 +190,7 @@ export class LocalBashExecutor extends BashExecutor { return [...this.tasks.values()] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) @@ -213,7 +213,7 @@ export class LocalBashExecutor extends BashExecutor { } } - kill(id: string): boolean { + kill(id: BashTaskId): boolean { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) if (task.status !== 'running') return false diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 3dd7f7983a..f851e837d5 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import type {} from '@deepseek-ai/dsh-bash' +import { BashTaskId } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -155,7 +155,7 @@ describe('LocalBashExecutor background tasks', () => { it('readOutput throws for unknown ids', async () => { const { bash } = await setup() - expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/) + expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/) }) it('kill terminates the process group and reports status killed', async () => { @@ -172,7 +172,7 @@ describe('LocalBashExecutor background tasks', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done expect(bash.kill(task.id)).toBe(false) - expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/) + expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/) }) it('notifies onTaskDone listeners on completion', async () => { diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 1c27a33a89..51ae489658 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/brand" + }, { "path": "../../bash/bash" } diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ce8816dee7..6123565e7a 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -28,4 +28,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts. diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 52bf80282f..66c02408d1 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -20,9 +20,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index f4e2d964fe..01c5c081c3 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -15,8 +15,9 @@ */ import { Context, Service } from 'cordis' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' +export { BashTaskId, OwnerToken } from './types.ts' export type { BashExecRequest, BashExecSpec, @@ -86,7 +87,7 @@ export abstract class BashExecutor extends Service { abstract start(spec: BashExecSpec): BashTask /** Look up a background task by id. */ - abstract get(id: string): BashTask | undefined + abstract get(id: BashTaskId): BashTask | undefined /** * The opaque OWNER token recorded for a background task at {@link start} @@ -101,19 +102,19 @@ export abstract class BashExecutor extends Service { * Storing ownership in the executor (disposed with ITS fiber) — not in the * tool plugin — is what makes ownership survive a `tool-bash` HMR reload. */ - abstract ownerOf(id: string): string | undefined + abstract ownerOf(id: BashTaskId): OwnerToken | undefined /** All tracked background tasks (insertion order). */ abstract list(): BashTask[] /** Read output produced since the previous read. Throws for unknown ids. */ - abstract readOutput(id: string): BashTaskRead + abstract readOutput(id: BashTaskId): BashTaskRead /** * Kill a running background task. Returns false when it had already * finished (no-op). Throws for unknown ids. */ - abstract kill(id: string): boolean + abstract kill(id: BashTaskId): boolean /** * Register a background-task completion listener (disposed with the diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index e731110698..d9ab9f9b4d 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -6,6 +6,31 @@ * @module dsh-bash/types */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identifies one background task within an executor (generated `bash-N`). */ +export type BashTaskId = Branded<'BashTaskId'> + +/** Brand a string as a {@link BashTaskId}. */ +export function BashTaskId(id: string): BashTaskId { + return id as BashTaskId +} + +/** + * A background task's opaque isolation key — the CONSUMER's owner identity, not + * the bash seam's. The executor stores and returns it verbatim and never + * interprets it; the access policy lives in the consumer (`dsh-tool-bash`), + * which is the single boundary that casts its own id vocabulary into one. A + * DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a + * sandboxed/remote executor inherits no session dependency. + */ +export type OwnerToken = Branded<'OwnerToken'> + +/** Brand a string as an {@link OwnerToken}. */ +export function OwnerToken(id: string): OwnerToken { + return id as OwnerToken +} + /** * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and * filled by {@link BashExecutor.resolve} from the implementation's config. @@ -28,7 +53,7 @@ export interface BashExecRequest { * seam — that is the consumer's job). Absent for foreground runs and for an * ownerless background start (a non-agent caller). */ - owner?: string | undefined + owner?: OwnerToken | undefined } /** @@ -53,7 +78,7 @@ export interface BashExecSpec { * silently-absent property that yields an unowned (cross-session-readable) * task. `start()` stores it; `run()` (foreground) ignores it. */ - owner: string | undefined + owner: OwnerToken | undefined } /** One captured stream: the (possibly truncated) text plus recovery info. */ @@ -87,7 +112,7 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed' /** A tracked background task handle. */ export interface BashTask { - readonly id: string + readonly id: BashTaskId readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 4b28bb72be..81530843ed 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { BashExecutor } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' /** Minimal concrete executor: records calls, lets tests drive completions. */ class StubExecutor extends BashExecutor { - tasks = new Map() - private owners = new Map() + tasks = new Map() + private owners = new Map() resolve(request: BashExecRequest): BashExecSpec { return { @@ -32,7 +32,7 @@ class StubExecutor extends BashExecutor { start(spec: BashExecSpec): BashTask { const task: BashTask = { - id: `stub-${this.tasks.size + 1}`, + id: BashTaskId(`stub-${this.tasks.size + 1}`), command: spec.command, status: 'running', exitCode: null, @@ -44,11 +44,11 @@ class StubExecutor extends BashExecutor { return task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return this.tasks.get(id) } - ownerOf(id: string): string | undefined { + ownerOf(id: BashTaskId): OwnerToken | undefined { return this.owners.get(id) } @@ -56,13 +56,13 @@ class StubExecutor extends BashExecutor { return [...this.tasks.values()] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) return { task, delta: '', lossy: false } } - kill(id: string): boolean { + kill(id: BashTaskId): boolean { const task = this.tasks.get(id) if (!task) throw new Error(`unknown bash task "${id}"`) if (task.status !== 'running') return false diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 10dabc415e..0e8e8c1878 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 0ea01ca50d..9ad1f9a17c 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -43,6 +43,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' @@ -79,11 +80,11 @@ function validateBashArgs(args: { * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the * DSL can't express, is left to check here. */ -function validateTaskId(value: string): string { +function validateTaskId(value: string): BashTaskId { if (value.length === 0) { throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`) } - return value + return BashTaskId(value) } /** Append the truncation notice (with the full-output spill path) to a stream's text. */ @@ -279,7 +280,8 @@ export function apply(ctx: Context): void { * the conventions flag. The two are equal in production, but the header is the * canonical identity. */ - const callerToken = (exec: { agent?: Agent }): string | undefined => exec.agent?.session.header.id + const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined => + exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined /** * Authorize a `bash_output`/`bash_kill` call against the task's stored owner @@ -291,7 +293,7 @@ export function apply(ctx: Context): void { * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller * (`callerToken` undefined) cannot match an owned task and is rejected. */ - const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => { + const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => { const owner = ctx.bash.ownerOf(taskId) if (owner !== undefined && owner !== callerToken(exec)) { throw new Error(`task ${taskId} belongs to another session`) @@ -310,7 +312,7 @@ export function apply(ctx: Context): void { ctx.bash.onTaskDone((task) => { const ownerToken = ctx.bash.ownerOf(task.id) if (ownerToken === undefined) return - const agent = ctx.get('agents')?.list().find(a => a.session.header.id === ownerToken) + const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken) if (!agent) return try { agent.inject( diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 0ab786ca85..a67809ffee 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,9 +5,10 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { BashTaskId } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -73,7 +74,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-fg', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -105,7 +106,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-exit', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -126,7 +127,7 @@ describe('bash tool through the agent loop', () => { let taskId = '' const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('it-bg', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' }) // Intercept the first tool result to capture the generated task id, then // rewrite the second scripted call's arguments to use it. @@ -147,7 +148,7 @@ describe('bash tool through the agent loop', () => { await waitForIdle(ctx, agent) // Wait for the background task itself (completion may race turn end). - const task = ctx.bash.get(taskId) + const task = ctx.bash.get(BashTaskId(taskId)) if (!task) throw new Error(`task ${taskId} not registered`) await task.done diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c49410a9b2..33c392ff7e 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -4,8 +4,8 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { class LossyReadBashExecutor extends BashExecutor { private readonly task: BashTask = { - id: 'bash-lossy', + id: BashTaskId('bash-lossy'), command: 'fake', status: 'running', exitCode: null, @@ -94,11 +94,11 @@ class LossyReadBashExecutor extends BashExecutor { return this.task } - get(id: string): BashTask | undefined { + get(id: BashTaskId): BashTask | undefined { return id === this.task.id ? this.task : undefined } - ownerOf(): string | undefined { + ownerOf(): OwnerToken | undefined { return undefined } @@ -106,7 +106,7 @@ class LossyReadBashExecutor extends BashExecutor { return [this.task] } - readOutput(id: string): BashTaskRead { + readOutput(id: BashTaskId): BashTaskRead { if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`) return { task: this.task, delta: 'tail', lossy: true } } @@ -279,7 +279,7 @@ describe('background tools', () => { it('bash_output polls incrementally and reports status', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await new Promise(resolve => setTimeout(resolve, 150)) const first = await call(ctx, 'bash_output', { task_id: id }) @@ -305,7 +305,7 @@ describe('background tools', () => { 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 }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done const read = await call(ctx, 'bash_output', { task_id: id }) expect(text(read)).toContain('[some output was dropped from memory; full output: ') @@ -325,7 +325,7 @@ describe('background tools', () => { it('bash_kill stops a running task; repeat reports already-finished', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const killed = await call(ctx, 'bash_kill', { task_id: id }) expect(text(killed)).toBe(`killed background task ${id}`) @@ -372,7 +372,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done expect(inject).toHaveBeenCalledTimes(1) @@ -395,7 +395,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) @@ -414,7 +414,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done // notifyTaskDone caught and logged the rethrown error. expect(errorSpy).toHaveBeenCalled() @@ -440,7 +440,7 @@ describe('background tools', () => { arguments: { command: 'true', description: 'test command', run_in_background: true }, agent, }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Unregister the agent BEFORE the task completes (simulate disconnect). unregisterFakeAgents(ctx) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() @@ -450,7 +450,7 @@ describe('background tools', () => { it('does not notify when no agent owned the task', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined() }) }) @@ -474,7 +474,7 @@ describe('background task ownership (cross-session isolation)', () => { const b = fakeAgent('sess-b') // Agent A starts a long-running background task. const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Agent B (a different session token) cannot read or kill A's task. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) @@ -498,7 +498,7 @@ describe('background task ownership (cross-session isolation)', () => { const a1 = fakeAgent('sess-shared') const a2 = fakeAgent('sess-shared') // distinct object, same token const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id }) expect(readByA2.isError).toBe(false) await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup @@ -508,7 +508,7 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = await setup() const a = fakeAgent('sess-a') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // A call with no exec.agent has no token → cannot prove ownership of an owned task. const read = await callAs(ctx, undefined, 'bash_output', { task_id: id }) expect(read.isError).toBe(true) @@ -520,7 +520,7 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = await setup() // Started by a non-loop caller (no exec.agent) → no owner token recorded. const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Any agent (and the no-agent caller) may read/kill it. const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id }) expect(read.isError).toBe(false) @@ -533,7 +533,7 @@ describe('background task ownership (cross-session isolation)', () => { const a = fakeAgent('sess-a') const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) await ctx.bash.get(id)!.done // Completion does NOT clear ownership: B is still rejected, A still allowed. const readByB = await callAs(ctx, b, 'bash_output', { task_id: id }) @@ -559,7 +559,7 @@ describe('background task ownership (cross-session isolation)', () => { const a = fakeAgent('sess-a') const b = fakeAgent('sess-b') const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) // Before reload: B is rejected (A owns it). expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true) @@ -674,7 +674,7 @@ describe('status lines', () => { it('reports kills without a recorded signal (executor raced process exit)', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const task = ctx.bash.get(id)! await call(ctx, 'bash_kill', { task_id: id }) @@ -688,7 +688,7 @@ describe('status lines', () => { it('reports completed tasks with a null exit code as exit 0', async () => { const ctx = await setup() const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true }) - const id = /task (bash-\d+)/.exec(text(started))![1]! + const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!) const task = ctx.bash.get(id)! await task.done // Defensive: completed tasks always carry an exit code in practice; the diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 5c25eb197d..90d641eeeb 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,8 +10,7 @@ import { Context, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' -import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -33,7 +32,7 @@ declare module 'cordis' { export interface Config { /** Agents created from configuration at startup. */ agents: (AgentOptions & { - id: string + id: AgentId /** * If set, the config agent RESUMES this persisted session id instead of * starting a fresh `${id}-session-`. Sourced from an env var in @@ -42,8 +41,12 @@ export interface Config { * `dsh-session-persistence` backend; the resume is deferred until that * service is available (via `ctx.inject`) and the loaded session's events * seed the live session so history continues. + * + * The schema accepts a plain string at runtime (cordis.yml values are + * untyped); the brand is compile-time only — the config format is the + * boundary where an id enters, so the TYPE declares the brand here. */ - resumeSessionId?: string + resumeSessionId?: SessionId })[] } @@ -60,14 +63,19 @@ export interface Config { export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] - static Config: z = z.object({ + // The schema validates plain strings (cordis.yml config values are untyped at + // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId` + // because the config format is the boundary where an id enters. The brand is a + // zero-cost compile-time cast, so the runtime schema stays string-based and we + // assert the branded view once here — the single schema boundary. + static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), model: z.string(), systemPrompt: z.string(), resumeSessionId: z.string(), })).default([]), - }) + }) as unknown as z constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') @@ -118,14 +126,14 @@ export class AgentLoop extends Service implements AgentFactory { * fork seeds the new Session with the parent's event log, spawn starts * fresh; the child is returned as a regular Agent handle. */ - create(id: string, options: AgentOptions = {}): ReactLoopAgent { + create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) // Config/programmatic path: prepare the session and let start() fold its // lifecycle into the agent's composite effect (so a fiber unload tears the // session + agent down as one ordered chain, capturing the loop's closing // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. - const session = this.ctx.sessions.prepare(`${id}-session-${randomUUID()}`, { meta: {} }) - const { agent } = this.start(AgentId(id), options, session) + const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} }) + const { agent } = this.start(id, options, session) return agent } @@ -142,7 +150,7 @@ export class AgentLoop extends Service implements AgentFactory { // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) + return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } /** @@ -191,7 +199,7 @@ export class AgentLoop extends Service implements AgentFactory { */ private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { this.assertAgentIdFree(options.agentId) - const { meta, events } = await persistence.load(SessionId(options.resumeSessionId)) + const { meta, events } = await persistence.load(options.resumeSessionId) // Re-check the agent id AFTER the await: the pre-load check above can go // stale while load() is pending (a concurrent resume/create may register the // same id). Re-checking immediately before prepare()/start keeps the @@ -211,7 +219,7 @@ export class AgentLoop extends Service implements AgentFactory { ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, }, }) - return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session) + return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } /** @@ -220,7 +228,7 @@ export class AgentLoop extends Service implements AgentFactory { * persistence state) behind. `register()` enforces the same uniqueness, but * only after the session has already entered the store. */ - private assertAgentIdFree(id: string): void { + private assertAgentIdFree(id: AgentId): void { if (this.ctx.agents.get(id) !== undefined) { throw new Error(`agent "${id}" is already registered`) } diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7c46df956c..d9235cfef3 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -53,7 +53,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -68,7 +68,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -83,7 +83,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -96,7 +96,7 @@ describe('ReactLoopAgent', () => { it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Simulate an OPEN turn in the log while the agent is idle (status is not a // reliable open-turn signal). inject must append into that open turn, NOT @@ -122,7 +122,7 @@ describe('ReactLoopAgent', () => { // A persistence-like listener whose flush rejects. ctx.on('session/flush', () => { throw new Error('disk gone') }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // inject() is synchronous and fires a fire-and-forget flush; a rejecting // flush must be contained (logged), never thrown into the caller. @@ -135,7 +135,7 @@ describe('ReactLoopAgent', () => { it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) @@ -155,7 +155,7 @@ describe('ReactLoopAgent', () => { it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // A session/event listener that throws on the synthetic turn/end. Append @@ -180,7 +180,7 @@ describe('ReactLoopAgent', () => { // A non-Error rejection exercises the String() normalization branch. ctx.on('session/flush', () => { throw 'disk gone' }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const errors: { turn: number; step: number; message: string }[] = [] ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) @@ -199,7 +199,7 @@ describe('ReactLoopAgent', () => { it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // A non-serializable source makes the turn/start append throw BEFORE the // event is pushed (Session.append validates before push), so NO turn opens. @@ -214,7 +214,7 @@ describe('ReactLoopAgent', () => { it('steer() when idle falls through to send() and starts a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -230,7 +230,7 @@ describe('ReactLoopAgent', () => { // Then call it twice — the second call hits the early-return branch. const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('test') + const session = ctx.sessions.create(SessionId('test')) const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) // Start the loop to get the disposer; the agent waits for messages @@ -249,7 +249,7 @@ describe('ReactLoopAgent', () => { it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -268,7 +268,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() resolves immediately when the agent is not running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -279,7 +279,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() waits for queued work that has not flipped status yet', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'queued') let settled = false @@ -297,8 +297,8 @@ describe('ReactLoopAgent', () => { it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) - const other = ctx.agentLoop.create('a2', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -333,7 +333,7 @@ describe('ReactLoopAgent', () => { await ctx.plugin(AgentRegistry) const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) - const session = ctx.sessions.create('bare') + const session = ctx.sessions.create(SessionId('bare')) const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) const dispose = agent.start() agent.send([{ type: 'text', text: 'go' }]) @@ -357,7 +357,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -378,7 +378,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -399,7 +399,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -417,7 +417,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) @@ -434,7 +434,7 @@ describe('ReactLoopAgent', () => { it('abort() resolves reason to "aborted" when no reason provided', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: { kind: string; reason?: string }[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index a4e9e13a1c..4a417dbdce 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -15,7 +15,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -56,7 +56,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. @@ -73,7 +73,7 @@ describe('Agent.cancel()', () => { it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. @@ -92,7 +92,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Queue work, then register a whenIdle() waiter while in the pre-step window // (status idle, hasQueued true) — it does NOT take the fast path. Then cancel. @@ -113,7 +113,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) @@ -130,7 +130,7 @@ describe('Agent.cancel()', () => { it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) @@ -146,7 +146,7 @@ describe('Agent.cancel()', () => { it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -168,7 +168,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // A turn-start listener fires BEFORE any AbortController is installed for the // step. Cancelling there must still drop the step (the turn-scoped marker, @@ -200,7 +200,7 @@ describe('Agent.cancel()', () => { // `aborted` and run NO second step. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-start', () => { steps += 1 }) @@ -230,7 +230,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running // listener can cancel in the gap between the loop's pre-step check and @@ -260,7 +260,7 @@ describe('Agent.cancel()', () => { // so whenIdle() resolves on the replacement turn's running→idle, not before. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -290,7 +290,7 @@ describe('Agent.cancel()', () => { // settle (the quiescence contract), not resolve before B's first event. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'A') // queues A (status still idle, loop microtask pending) const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) @@ -310,7 +310,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index d4753432bc..8cf5bd81f8 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -4,10 +4,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -35,10 +35,10 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get('cfg') as ReactLoopAgent + const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent expect(a1.session.id).toMatch(idPattern) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -52,10 +52,10 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get('cfg') as ReactLoopAgent + const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -78,7 +78,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -92,7 +92,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'sticky-1' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -100,7 +100,7 @@ describe('config-driven session id', () => { let resumed: ReactLoopAgent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined + resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), @@ -120,7 +120,7 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'does-not-exist' }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) @@ -129,7 +129,7 @@ describe('config-driven session id', () => { // The deferred resume fails (no such session on disk). It must be contained: // a warning is logged, no 'main' agent is registered, and the app stays up. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get('main')).toBeUndefined() + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 96c061d2dd..12036d7aec 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -43,7 +43,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => // The second turn should proceed normally and consume the first script entry. const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-start', () => { @@ -73,7 +73,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-end', () => { @@ -107,7 +107,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () => // driver survives. This is the ONLY path that reaches the backstop. const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const errors: { turn: number; step: number; message: string }[] = [] ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) @@ -149,7 +149,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -182,7 +182,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -195,7 +195,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from turn-start listeners via toError', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-start', () => { @@ -221,7 +221,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -249,7 +249,7 @@ describe('coded error data emission', () => { it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -283,7 +283,7 @@ describe('disposed vs aborted branching', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -311,7 +311,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f004e02f91..cd912f39cd 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -44,7 +44,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const order: string[] = [] for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) { @@ -85,7 +85,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -120,7 +120,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -133,7 +133,7 @@ describe('agent loop', () => { it('records raw chunks for replay and emits agent/stream-chunk', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const streamed: StreamChunk[] = [] ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk)) @@ -161,7 +161,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -193,7 +193,7 @@ describe('agent loop', () => { it('steering while idle behaves like send (starts a turn)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -203,7 +203,7 @@ describe('agent loop', () => { it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) // The idle inject records a self-contained turn (turn/start → context/message @@ -230,7 +230,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // A tool that injects mid-execution: at this point the agent is running, so // inject must append the context/message into the ALREADY-open turn rather // than wrap it in its own one-shot turn. @@ -264,7 +264,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-end', () => void steps++) @@ -290,7 +290,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async () => false as const) @@ -306,7 +306,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.llm.registerAdapter(['other-model'], adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { options.model = 'other-model' @@ -321,7 +321,7 @@ describe('agent loop', () => { it('abort() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -341,7 +341,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -366,7 +366,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 ctx.on('agent/step-end', () => void steps++) @@ -397,7 +397,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -430,7 +430,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -461,7 +461,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -488,7 +488,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threw = false ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('bad step-end listener') } @@ -505,7 +505,7 @@ describe('agent loop', () => { it('chains queued messages into consecutive turns', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const turns: number[] = [] ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) @@ -530,7 +530,7 @@ describe('agent loop', () => { it('awaits session/flush at turn end (persistence checkpoint)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -550,7 +550,7 @@ describe('agent loop', () => { it('errors from the model surface as agent/error and end the turn', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -572,10 +572,10 @@ describe('agent loop', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get('scoped')).toBe(agent) + expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -584,7 +584,7 @@ describe('agent loop', () => { await agent.done expect(agent.status).toBe('disposed') - expect(ctx.agents.get('scoped')).toBeUndefined() + expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -597,11 +597,11 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: 'config-agent', model: 'mock', systemPrompt: 'Config prompt' }], + agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get('config-agent')! as ReactLoopAgent + const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent expect(agent).toBeDefined() expect(agent.id).toBe('config-agent') expect(agent.options.model).toBe('mock') @@ -626,11 +626,11 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types expect(replayed.events.map(e => e.type)).toEqual( diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index da457f4ddc..1e603a1cc4 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -17,7 +17,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -95,7 +95,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. @@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -145,7 +145,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create('a', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) // Capture an idle waiter before EACH send; the last one is guaranteed // to resolve because the final send always triggers (or joins) a turn // that ends idle. Awaiting an already-resolved waiter is a no-op, so a diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bc655a32f2..6192396cab 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,7 +8,7 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } }) + const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() @@ -52,18 +52,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - ctx.agents.create({ agentId: 'dup', sessionId: 'sess-a' }) + ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) // A second create with the SAME agent id but a fresh session id must reject // up front — and must NOT leave an orphaned 'sess-b' session behind. - expect(() => ctx.agents.create({ agentId: 'dup', sessionId: 'sess-b' })).toThrow(/already registered/) - expect(ctx.sessions.get('sess-b')).toBeUndefined() + expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/) + expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() await ctx.fiber.dispose() }) it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' }) + const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -104,7 +104,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ] const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const forked = ctx1.sessions.create('forked-sess', { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) + const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) await ctx1.parallel('session/flush', forked) await ctx1.fiber.dispose() @@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') await ctx2.fiber.dispose() @@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -234,7 +234,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - await expect(ctx.agents.resume({ agentId: 'm', resumeSessionId: 'nope' })) + await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 8fc375457a..fa6a560c7f 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -55,7 +55,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -106,7 +106,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -154,7 +154,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/step-end', () => { @@ -176,7 +176,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -198,7 +198,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-end', () => { @@ -223,7 +223,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steering queued during an aborted step is re-delivered, not silently consumed', async () => { const adapter = new MockAdapter(['hang', textResponse('recovered')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -241,7 +241,7 @@ describe('HIGH: plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -269,7 +269,7 @@ describe('HIGH: plugin exceptions are contained', () => { it('a rejecting session/flush listener is reported but does not kill the agent', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -299,7 +299,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -322,7 +322,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('scoped', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -335,7 +335,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { await agent.done // must not hang expect(agent.status).toBe('disposed') - expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -354,7 +354,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', {}) // no model + const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -369,7 +369,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('the agent/request waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { options.model = 'mock' @@ -385,7 +385,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('agent/queued carries the resolved source; agent/steering carries its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -414,7 +414,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) - const agent = ctx.agentLoop.create('a1', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) ctx2.effect(() => forked.start()) @@ -475,7 +475,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-error', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -498,7 +498,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-aborted', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -516,7 +516,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-finish-error-nocode', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) @@ -532,7 +532,7 @@ describe('P1-6: step/start is appended before agent/step-start is emitted', () = it('a step-start listener sees the step/start event already in session.events', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-step-order', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) // Capture, at the moment agent/step-start fires, whether the matching // step/start event is already in the log (append-before-emit, the event-sourcing RFC). @@ -591,7 +591,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-turnstart', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' }) let threw = false ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } }) @@ -613,7 +613,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-stepstart', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) let threw = false ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } }) @@ -642,7 +642,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-errorlistener', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -675,7 +675,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('a-dispose', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -706,7 +706,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) // The FIRST agent/turn-end emit throws (the disposal-driven turn end). @@ -749,7 +749,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // subscriber.) const adapter = new MockAdapter([textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-preturn', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -788,7 +788,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // surfaced via agent/error instead, and the log's last event is turn/end. const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-tend', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' }) let threw = false ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) @@ -820,7 +820,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // swallowed the throw in the normal (no-tool, no-steering) path. const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-stepend-throw', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) let threw = false ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } }) @@ -862,7 +862,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create('a-double', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' }) let threw = false ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) @@ -897,7 +897,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-errthrow', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-errthrow'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -930,7 +930,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // throw is contained + surfaced via failTurn, so turn/end is still appended. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-stependthrow', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) // Open a step, then make the agent/step-start emit throw (boundary throw → // outer catch → closeStep during finalization). @@ -968,7 +968,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // what throws.) const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create('a-turnendappend', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1014,7 +1014,7 @@ describe('P1-7: tool/result is logged under the originating call.id, not result. return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false }) }, { prepend: true }) - const agent = ctx.agentLoop.create('a-callid', { model: 'mock' }) + const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index df4163670d..8e9d41855c 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -9,7 +9,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- `ctx.agents.get(id: string): Agent | undefined` +- `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index a215f35fb3..408fd2ae69 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -20,11 +20,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index cd66156052..158946178c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentOptions } from './types.ts' +import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' @@ -26,9 +26,9 @@ declare module 'cordis' { */ export interface CreateAgentOptions { /** The agent's id (the registry handle). */ - agentId: string + agentId: AgentId /** The live session's id (NOT derived from agentId). */ - sessionId: string + sessionId: SessionId /** * Session creation metadata: validated absolute `cwd` and `parentSession` * fork lineage. Mirrors the `cwd`/`parentSession` fields of @@ -47,9 +47,9 @@ export interface CreateAgentOptions { */ export interface ResumeAgentOptions { /** The agent's id (the registry handle). */ - agentId: string + agentId: AgentId /** The persisted session id to load and resume on. */ - resumeSessionId: string + resumeSessionId: SessionId /** Per-agent options (model, system prompt). */ agentOptions?: AgentOptions } @@ -103,7 +103,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() + private store = new Map() private factory: AgentFactory | undefined constructor(ctx: Context) { @@ -188,7 +188,7 @@ export class AgentRegistry extends Service { return () => void dispose() } - get(id: string): Agent | undefined { + get(id: AgentId): Agent | undefined { return this.store.get(id) } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d6b35b97a1..ef20bf2705 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,7 +9,8 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded, ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 98f072ab10..ff952aee4f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -32,12 +32,12 @@ describe('AgentRegistry', () => { const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) expect(created).toEqual(['a1']) - expect(ctx.agents.get('a1')).toBe(agent) + expect(ctx.agents.get(AgentId('a1'))).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) dispose() expect(disposed).toEqual(['a1']) - expect(ctx.agents.get('a1')).toBeUndefined() + expect(ctx.agents.get(AgentId('a1'))).toBeUndefined() }) it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => { @@ -66,14 +66,14 @@ describe('AgentRegistry', () => { // The throwing emit must roll the entry back, not leak it. expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener') - expect(ctx.agents.get('main')).toBeUndefined() // rolled back, not leaked + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked // A subsequent listener-free register of the SAME id succeeds and is // tracked exactly once (the duplicate-id check is not wedged). const dispose = ctx.agents.register(stubAgent('main')) expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) dispose() - expect(ctx.agents.get('main')).toBeUndefined() + expect(ctx.agents.get(AgentId('main'))).toBeUndefined() }) }) @@ -97,8 +97,8 @@ describe('AgentRegistry factory seam', () => { it('create()/resume() throw when no factory is registered', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).toThrow(/no agent factory/) - await expect(ctx.agents.resume({ agentId: 'a', resumeSessionId: 's' })).rejects.toThrow(/no agent factory/) + expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/) + await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) }) it('setFactory registers a factory; create/resume delegate to it', async () => { @@ -107,13 +107,13 @@ describe('AgentRegistry factory seam', () => { const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) - const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }) + const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) expect(created.agent.id).toBe('c1') - expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }]) + expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }]) - const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' }) + const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) expect(resumed.agent.id).toBe('r1') - expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }]) + expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }]) }) it('setFactory rejects a second factory', async () => { @@ -130,10 +130,10 @@ describe('AgentRegistry factory seam', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { dispose = inner.agents.setFactory(stubFactory().factory) }, { inject: ['agents'] })) - expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).not.toThrow() + expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow() void dispose await fiber.dispose() // factory slot cleared → create throws again - expect(() => ctx.agents.create({ agentId: 'a2', sessionId: 's2' })).toThrow(/no agent factory/) + expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/) }) }) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index e7d274f2cd..7a8eaa6e17 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" }, diff --git a/packages/core/session/README.md b/packages/core/session/README.md index dabe316a28..8c2f62cc1b 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber. -- `ctx.sessions.get(id: string): Session | undefined` +- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber. +- `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives diff --git a/packages/core/session/package.json b/packages/core/session/package.json index f4aa5839bc..8bd0d98abe 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -20,10 +20,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 65fbe01015..f86916d37f 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -220,7 +220,7 @@ export class Session { * subscribe to `session/event` and flush on `session/flush` / dispose. */ export class SessionStore extends Service { - private store = new Map() + private store = new Map() private counter = 0 constructor(ctx: Context) { @@ -244,7 +244,7 @@ export class SessionStore extends Service { * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ - create(id?: string, options?: CreateSessionOptions): Session { + create(id?: SessionId, options?: CreateSessionOptions): Session { const session = this.prepare(id, options) // Single effect owned by the calling fiber. Yield the detach BEFORE // announcing so a throwing `session/created` listener rolls the attach back @@ -269,7 +269,7 @@ export class SessionStore extends Service { * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path. */ - prepare(id?: string, options?: CreateSessionOptions): Session { + prepare(id?: SessionId, options?: CreateSessionOptions): Session { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) const cwd = options?.meta?.cwd @@ -321,7 +321,7 @@ export class SessionStore extends Service { this.ctx.emit('session/created', session) } - get(id: string): Session | undefined { + get(id: SessionId): Session | undefined { return this.store.get(id) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index cd1605d93a..4b7334b207 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,4 +1,5 @@ -import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 593eed36c0..075d106948 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -213,11 +213,11 @@ describe('SessionStore', () => { it('rejects duplicate ids and supports seeding', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const a = ctx.sessions.create('fixed') - expect(() => ctx.sessions.create('fixed')).toThrow('already exists') + const a = ctx.sessions.create(SessionId('fixed')) + expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists') a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) - const forked = ctx.sessions.create('fork', { seed: [...a.events] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -228,11 +228,11 @@ describe('SessionStore', () => { // the REAL session, breaking the store-uniqueness invariant. const ctx = new Context() await ctx.plugin(SessionStore) - const stale = ctx.sessions.prepare('racy') - const live = ctx.sessions.create('racy') + const stale = ctx.sessions.prepare(SessionId('racy')) + const live = ctx.sessions.create(SessionId('racy')) expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/) // The live session is intact and still the store entry. - expect(ctx.sessions.get('racy')).toBe(live) + expect(ctx.sessions.get(SessionId('racy'))).toBe(live) }) it('prepare() + enter() + announce() register a session and emit session/created', async () => { @@ -241,24 +241,24 @@ describe('SessionStore', () => { const created: Session[] = [] ctx.on('session/created', session => void created.push(session)) - const session = ctx.sessions.prepare('lifecycle') + const session = ctx.sessions.prepare(SessionId('lifecycle')) // prepare alone does NOT enter the store. - expect(ctx.sessions.get('lifecycle')).toBeUndefined() + expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() const detach = ctx.sessions.enter(session) - expect(ctx.sessions.get('lifecycle')).toBe(session) + expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session) // enter does NOT announce. expect(created).toEqual([]) ctx.sessions.announce(session) expect(created).toEqual([session]) // The detach disposer removes the entry + stops notification. detach() - expect(ctx.sessions.get('lifecycle')).toBeUndefined() + expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) it('synthesizes a minimal v1 header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('plain') + const session = ctx.sessions.create(SessionId('plain')) expect(session.header).toMatchObject({ version: 1, id: 'plain' }) expect(typeof session.header.createdAt).toBe('number') expect(session.header.cwd).toBeUndefined() @@ -268,7 +268,7 @@ describe('SessionStore', () => { it('attaches cwd and parentSession from meta to the header', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const session = ctx.sessions.create('child', { + const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/work/project', parentSession: SessionId('parent') }, }) expect(session.header).toMatchObject({ @@ -282,10 +282,10 @@ describe('SessionStore', () => { it('rejects a non-absolute meta.cwd', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } })) + expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } })) .toThrow(/cwd must be an absolute path/) // the rejected session was not registered - expect(ctx.sessions.get('rel')).toBeUndefined() + expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined() }) it('a bare Session() constructed without the store still exposes a v1 header', () => { @@ -300,15 +300,15 @@ describe('SessionStore', () => { let session!: Session const fiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('scoped') + session = inner.sessions.create(SessionId('scoped')) }, { inject: ['sessions'] })) - expect(ctx.sessions.get('scoped')).toBe(session) + expect(ctx.sessions.get(SessionId('scoped'))).toBe(session) let observed = 0 ctx.on('session/event', () => void observed++) await fiber.dispose() - expect(ctx.sessions.get('scoped')).toBeUndefined() + expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined() session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }) expect(observed).toBe(0) }) @@ -323,15 +323,15 @@ describe('SessionStore', () => { }) // The throwing emit must roll the store entry back, not leak it. - expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener') - expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked + expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener') + expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked // A subsequent create of the SAME id succeeds (the already-exists check is // not wedged) and its onAppend is correctly wired (events observable). const events: SessionEvent[] = [] ctx.on('session/event', (_session, event) => void events.push(event)) - const session = ctx.sessions.create('fixed') - expect(ctx.sessions.get('fixed')).toBe(session) + const session = ctx.sessions.create(SessionId('fixed')) + expect(ctx.sessions.get(SessionId('fixed'))).toBe(session) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) expect(events).toHaveLength(1) }) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 3423a0e06c..ca6113bb3f 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 38b05dc007..e15cce8252 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -14,6 +14,7 @@ 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 { CallId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' import { toPiContext, toStreamChunks } from './convert.ts' @@ -69,8 +70,8 @@ type Payload = { stop?: unknown } -function rawToolArguments(options: GenerateOptions): Map { - const raw = new Map() +function rawToolArguments(options: GenerateOptions): Map { + const raw = new Map() for (const message of options.messages) { if (message.role !== 'assistant') continue for (const block of message.content) { @@ -116,7 +117,7 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA for (const call of message.tool_calls ?? []) { /* v8 ignore next -- malformed pi-ai payload guard: real tool calls always carry a string id */ if (typeof call.id !== 'string') continue - const raw = rawById.get(call.id) + const raw = rawById.get(CallId(call.id)) /* v8 ignore next -- pi-ai always emits a function object for assistant tool_calls; guard malformed payloads defensively */ if (raw !== undefined && call.function !== undefined) call.function.arguments = raw } diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 4610ff01c9..0ddc41386a 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -57,7 +57,7 @@ function parseArguments(raw: string): Record { * same id. */ export function toPiContext(options: GenerateOptions): PiContext { - const toolNames = new Map() + const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 317edc7ac2..0983c893d3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -20,9 +20,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index 38d69fe37b..3082cc0141 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -1,24 +1,15 @@ /** - * Branded (nominal) ID types. + * dsh-llm's owned branded id: `CallId` (tool-call correlation). * - * A brand makes structurally-identical strings non-interchangeable at the - * type level: an `AgentId` cannot be passed where a `CallId` is expected, - * even though both are strings at runtime. Construction goes through the - * per-type factory (a plain cast inside — zero runtime cost); comparison, - * logging, and serialization all behave as ordinary strings. - * - * Policy: core packages brand the IDs they own — `CallId` here (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding - * is for IDs that cross package boundaries and could plausibly be confused; - * not every string needs a brand. + * The `Branded` primitive itself lives in `@deepseek-ai/dsh-brand` (a + * zero-dependency type-only package) so every owner of a cross-boundary id can + * brand it without depending on dsh-llm; see that package's README for the + * nominal-typing policy. * * @module @deepseek-ai/dsh-llm/brand */ -declare const BRAND: unique symbol - -/** A string carrying a compile-time-only brand `B`. */ -export type Branded = string & { readonly [BRAND]: B } +import type { Branded } from '@deepseek-ai/dsh-brand' /** * Correlates a model-issued tool call with its result. Provider-issued for diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 10dabc415e..0e8e8c1878 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 86c4a9d08b..8fa02665eb 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -274,8 +274,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root }) - const a = ctx.sessions.create('sa') - const b = ctx.sessions.create('sb') + const a = ctx.sessions.create(SessionId('sa')) + const b = ctx.sessions.create(SessionId('sb')) a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }) b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -451,7 +451,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { // Session A materializes a log under id "reuse". const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create('reuse', { meta: { cwd: '/a' } }) + const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) for (const e of oneTurnLog()) a.append(e.type, e.data) }, { inject: ['sessions'] })) // Drain A, then dispose ITS fiber (the live session A is gone) while the @@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create('reuse', { meta: { cwd: '/a' } }) + b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) @@ -493,7 +493,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx2.plugin(Object.assign((inner: Context) => { - b = inner.sessions.create('x') // no cwd + b = inner.sessions.create(SessionId('x')) // no cwd }, { inject: ['sessions'] })) await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/) @@ -521,7 +521,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }] let bad!: Session await ctx.plugin(Object.assign((inner: Context) => { - bad = inner.sessions.create('divergent', { seed: tampered, meta: { cwd: '/a' } }) + bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/) }) @@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('a second live session reusing a bound id is rejected', async () => { // A live session materializes and owns the id. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - const a = inner.sessions.create('bound', { meta: { cwd: '/a' } }) + const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) a.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }, { inject: ['sessions'] })) @@ -539,7 +539,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx.sessionPersistence as unknown as { inits: Map> } let second!: Session await ctx.plugin(Object.assign((inner: Context) => { - second = inner.sessions.create('bound', { meta: { cwd: '/a' } }) + second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(second)) .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/) @@ -580,7 +580,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { - s = inner.sessions.create('exists-fault', { meta: { cwd } }) + s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) }, { inject: ['sessions'] })) await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() @@ -645,7 +645,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) - const session = ctx2.sessions.create('flush-fail') + const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -689,7 +689,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { - const session = ctx.sessions.create('reject-bad') + const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a // BigInt-bearing event BEFORE it enters session.events, so the durable log // can never diverge from the live log. The throw surfaces at the caller's 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 262a085ce2..2bc9c59643 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' import { openDatabase, scanRows, type EventRow } from '../src/schema.ts' @@ -352,7 +352,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { const path = await freshDbPath() // Instance 1 materializes a session and disposes. const b1 = await backend(path) - const s1 = b1.ctx.sessions.create('hmr-collide') + const s1 = b1.ctx.sessions.create(SessionId('hmr-collide')) for (const e of oneTurnLog()) s1.append(e.type, e.data) await b1.ctx.parallel('session/flush', s1) await b1.dispose() @@ -363,7 +363,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await ctx.plugin(SessionStore) let session!: Session await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('hmr-collide') + session = inner.sessions.create(SessionId('hmr-collide')) }, { inject: ['sessions'] })) session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) await ctx.plugin(SessionPersistenceSqlite, { path }) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 58f1246763..d0f8717ff1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -157,14 +157,14 @@ async function settledErrors(promises: Iterable>): Promise { /** Backend bookkeeping keyed by session id (NOT the live Session object). */ - private states = new Map() + private states = new Map() /** Write-behind buffers keyed by the live Session (write path). */ private buffers = new Map() /** * Per-session serialization: every operation chains onto the prior one for the * same id, so writes for one session never interleave. Keyed by session id. */ - private chains = new Map>() + private chains = new Map>() /** * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not * its id: a disposed fiber's session can be replaced by a different live diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 1d9a1339d1..d7c7ad44b4 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -85,7 +85,7 @@ async function liveSessionInFiber( ): Promise { let session!: Session await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(id, cwd !== undefined ? { meta: { cwd } } : undefined) + session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined) }, { inject: ['sessions'] })) return session } @@ -110,7 +110,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('live', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } }) send(session, oneTurnLog()) await ctx.parallel('session/flush', session) @@ -127,7 +127,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('mutate', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }) // Mutate the live event object AFTER it was buffered by session/event. ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' @@ -177,7 +177,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const seed = oneTurnLog() // A fork: a brand-new id whose seed came from elsewhere. - const forked = ctx.sessions.create('forked', { seed, meta: { cwd: WORK } }) + const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } }) await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed const loaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(loaded.events).toEqual(seed) @@ -196,7 +196,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const first = await freshCtx(fix) try { // First lifecycle: persist a session through the store. - const s1 = first.ctx.sessions.create('resumed', { meta: { cwd: WORK } }) + const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) } finally { @@ -209,7 +209,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const second = await freshCtx(fix) try { const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - const s2 = second.ctx.sessions.create('resumed', { seed: loaded.events, meta: { cwd: WORK } }) + const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) @@ -231,7 +231,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const ctx = new Context() await ctx.plugin(SessionStore) // A session exists BEFORE the persistence plugin is applied. - const session = ctx.sessions.create('pre-existing', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -371,7 +371,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const first = await freshCtx(fix) try { - const s1 = first.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) } finally { @@ -383,7 +383,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // exists. The rejection surfaces via the init promise (flush awaits it). const second = await freshCtx(fix) try { - const s2 = second.ctx.sessions.create('collide', { meta: { cwd: WORK } }) + const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await expect(inits(second.ctx.sessionPersistence).get(s2)) .rejects.toThrow(/already has a persisted log|id collision/) @@ -401,14 +401,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // never materialized. A new live session reusing the id must reclaim it. let firstSession!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - firstSession = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state await firstFiber.dispose() // disposed before any append → never materialized let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('abandoned', { meta: { cwd: WORK } }) + reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined() reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -428,7 +428,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { let first!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await inits(ctx.sessionPersistence).get(first) // Append a turn but do NOT flush — events sit in the write-behind buffer. @@ -438,7 +438,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create('buffered', { meta: { cwd: WORK } }) + reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/) } finally { @@ -451,7 +451,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create('idem', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) @@ -476,7 +476,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.create(meta('lazy-claim', WORK)) // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. - const live = ctx.sessions.create('lazy-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined() const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) @@ -500,7 +500,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // seq 0..cursor-1 events would otherwise be filtered as already-persisted. let fresh!: Session await ctx.plugin(Object.assign((inner: Context) => { - fresh = inner.sessions.create('preview', { meta: { cwd: WORK } }) + fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) await expect(inits(ctx.sessionPersistence).get(fresh)) .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/) @@ -521,7 +521,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session SEEDED with the loaded log PLUS a new turn claims the // ownerless state and persists only the suffix. - const cont = ctx.sessions.create('claim', { seed: [ + const cont = ctx.sessions.create(SessionId('claim'), { seed: [ ...events, { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, @@ -545,7 +545,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session reusing the id but at cwd WORK must NOT claim it — the // cwd scope is the fence (without it, WORK events would append under the // OTHER header). Rejected as a collision. - const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -563,7 +563,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load')) // A live session whose SEED matches the loaded prefix but whose cwd is // WORK must still be rejected — the cwd guard runs before the seed check. - const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -579,7 +579,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.sessionPersistence.create(meta('no-cwd-state')) // A live session reusing the id but WITH cwd WORK is a cwd mismatch // (undefined vs WORK) and must be rejected. - const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } }) + const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() @@ -703,7 +703,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Append directly to a live session and flush IMMEDIATELY, before the // async onCreated init has necessarily set state (exercises the // state-undefined cursor path). - const session = ctx.sessions.create('flush-nostate', { meta: { cwd: WORK } }) + const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } }) session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index b201e1ae43..9b4b4ddfa0 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -21,6 +21,7 @@ import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -65,7 +66,7 @@ interface SessionTrace { * Tool-call ids issued in the OPEN step awaiting a result. Cleared at * `step/end` — a result must arrive in the same step as its call. */ - pendingCalls: Set + pendingCalls: Set } /** diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 1086a21bfb..f1bf2a2e74 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -175,8 +175,8 @@ describe('session-log invariants', () => { it('tracks turns per session independently', async () => { const { ctx } = await setup({ freeze: false }) - const a = ctx.sessions.create('a') - const b = ctx.sessions.create('b') + const a = ctx.sessions.create(SessionId('a')) + const b = ctx.sessions.create(SessionId('b')) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) // b is a fresh session — its own turn/start must not see a's open turn. expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index d52370d1ed..4d93ed3f86 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -22,7 +22,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import type {} from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' export const name = 'ui-stdio' export const inject = ['agents'] @@ -69,7 +69,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Loader validation, so it must be self-contained rather than trusting the // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. const welcome = config.welcome ?? 'ready.' - const agentId = config.agent ?? 'main' + const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime let inReasoning = false diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 089379e83a..2964726b94 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -60,7 +60,10 @@ import { type StopReason, } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' 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, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto @@ -138,7 +141,7 @@ export const Config: Schema = Schema.object({ * map keyed by id (RFC 011 multi-session). */ interface SessionRecord { - sessionId: string + sessionId: SessionId agent: Agent /** * The owned-agent disposer (from the {@link AgentHandle} the factory returned). @@ -229,12 +232,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). // The two stay in lockstep: a record is added to `sessions` and the agent to // `bySession` together, and removed together. - const sessions = new Map() - const bySession = new WeakMap() + const sessions = new Map() + const bySession = new WeakMap() // Session ids whose `session/load` is mid-`resume()` (the slot is reserved // before the async resume so a pipelined load/new for the SAME id can't create // two agents). Distinct ids load concurrently; a given id loads once at a time. - const loadingIds = new Set() + const loadingIds = new Set() // Set once the bridge has torn down (disposal or client disconnect). An async // `session/load` mid-`resume()` when teardown ran must observe this after its // await and NOT install a record (which would resurrect a live agent/listeners @@ -265,7 +268,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /** Resolve the live record for a sessionId, or throw an ACP error. */ - const requireSession = (sessionId: string): SessionRecord => { + const requireSession = (sessionId: SessionId): SessionRecord => { const rec = sessions.get(sessionId) if (rec === undefined) { throw invalidParams(`unknown session: ${sessionId}`) @@ -446,9 +449,9 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() validateWorkspaceParams(params) validateMcpServers(params) - const sessionId = randomUUID() + const sessionId = SessionId(randomUUID()) const handle = agents.create({ - agentId: sessionId, + agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), @@ -467,8 +470,11 @@ export function apply(ctx: Context, config: AcpConfig): void { async loadSession(params: LoadSessionRequest): Promise { assertOpen() - if (sessions.has(params.sessionId) || loadingIds.has(params.sessionId)) { - throw invalidParams(`session ${params.sessionId} is already loaded`) + // The wire `params.sessionId` is a raw protocol string; brand it once at + // this entry so the session collections and the resume factory see a SessionId. + const sessionId = SessionId(params.sessionId) + if (sessions.has(sessionId) || loadingIds.has(sessionId)) { + throw invalidParams(`session ${sessionId} is already loaded`) } validateWorkspaceParams(params) validateMcpServers(params) @@ -477,7 +483,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // resume() is pending, then both install a record and leak a second // agent. (Distinct ids load concurrently — the set is keyed by id.) The // slot is released in `finally` so a rejected load never wedges the id. - loadingIds.add(params.sessionId) + loadingIds.add(sessionId) try { // Validate the PERSISTED cwd BEFORE resuming — `list()` is a // metadata-only read (no full-log parse), so this rejects a session we @@ -491,21 +497,21 @@ export function apply(ctx: Context, config: AcpConfig): void { // always has a cwd (session/new requires it); reject the rest loudly. // (An id unknown to `list()` falls through to resume, which rejects with // the backend's not-found error.) - const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId) + const meta = (await sessionPersistence.list()).find(m => m.id === sessionId) if (meta !== undefined) { const persistedCwd = meta.cwd if (persistedCwd === undefined || !isAbsolute(persistedCwd)) { throw invalidParams( - `session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`, + `session ${sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`, ) } if (!sameWorkspaceCwd(persistedCwd, params.cwd)) { - throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) + throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`) } } const handle = await agents.resume({ - agentId: params.sessionId, - resumeSessionId: params.sessionId, + agentId: AgentId(sessionId), + resumeSessionId: sessionId, agentOptions: agentOptions(config), }) // The bridge may have torn down (disposal / client disconnect) while @@ -523,20 +529,20 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams('connection closed during session/load') } const agent = handle.agent - bySession.set(agent, params.sessionId) + bySession.set(agent, sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId: params.sessionId, + sessionId, agent, dispose: () => handle.dispose(), presenter: makePresenter(), terminalEnabled, inflight: undefined, } - sessions.set(params.sessionId, record) + sessions.set(sessionId, record) // Replay the persisted event log to the client as session/update. Use // the raw event log (NOT deriveMessages, which drops assistant/chunk // and trace events): RFC 010's load contract reconstructs the streamed @@ -556,17 +562,17 @@ export function apply(ctx: Context, config: AcpConfig): void { cwd: agent.session.header.cwd, } for (const event of agent.session.events) { - streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal) + streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } return {} } finally { - loadingIds.delete(params.sessionId) + loadingIds.delete(sessionId) } }, async prompt(params: PromptRequest): Promise { assertOpen() - const rec = requireSession(params.sessionId) + const rec = requireSession(SessionId(params.sessionId)) if (rec.inflight !== undefined) { throw invalidParams('a prompt is already in flight for this session') } @@ -595,7 +601,7 @@ export function apply(ctx: Context, config: AcpConfig): void { }, cancel(params: CancelNotification): Promise { - const rec = sessions.get(params.sessionId) + const rec = sessions.get(SessionId(params.sessionId)) if (rec === undefined) return Promise.resolve() // session/cancel maps to the queue-aware agent.cancel(reason): it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a @@ -773,7 +779,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * no client update. */ export function streamSessionEventUpdate( - sessionId: string, + sessionId: SessionId, event: SessionEvent, notify: (notification: SessionNotification) => void, presenter: Pick = nullToolPresenter, @@ -938,7 +944,7 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map() + private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. @@ -954,7 +960,7 @@ export class ToolPresenter { ) {} /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */ - call(callId: string, name: string, argsJson: string): ResolvedCallPresentation { + call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation { const args = parseToolArguments(argsJson) let present: ToolCallPresentation | undefined try { @@ -986,7 +992,7 @@ export class ToolPresenter { } /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */ - result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { + result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index dd10a88bcb..e9ebca8d62 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' /** @@ -61,8 +62,8 @@ describe('acp bridge', () => { expect(b.sessionId).toBeTruthy() expect(a.sessionId).not.toBe(b.sessionId) // Both agents are live and independently registered. - expect(harness.ctx.agents.get(a.sessionId)).toBeDefined() - expect(harness.ctx.agents.get(b.sessionId)).toBeDefined() + expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -77,7 +78,7 @@ describe('acp bridge', () => { const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) expect(res.sessionId).toBeTruthy() // The session header records that cwd, so its bash tools run there. - expect(harness.ctx.agents.get(res.sessionId)!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -117,7 +118,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(sessionId)!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index dd27cc7e34..ac092d9d16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' +import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { @@ -16,7 +17,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -61,10 +62,10 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(harness.ctx.agents.get(sessionId)).toBeDefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -91,7 +92,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! // Start a prompt that hangs in the model stream. The prompt RPC will never // return (its transport is severed), so do not await it. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) @@ -114,8 +115,8 @@ describe('acp bridge — disposal & HMR safety', () => { // and its session removed from the store, not merely idled (the old // behavior). The services live on the root ctx, so they survive this. await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(sessionId)).toBeUndefined() - expect(harness.ctx.sessions.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -127,7 +128,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -144,7 +145,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const session = harness.ctx.agents.get(sessionId)!.session + const session = harness.ctx.agents.get(AgentId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -168,12 +169,12 @@ describe('acp bridge — disposal & HMR safety', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - const liveEvents = harness.ctx.agents.get(sessionId)!.session.events.length + const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() // Re-load the session from disk: every live event (incl. the closing // turn/end) was flushed before the session was detached. @@ -200,7 +201,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -210,7 +211,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(sessionId)).toBeUndefined() + expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not // self-report) — NOT a crash-recovery `interrupted` substitute. @@ -229,22 +230,22 @@ describe('acp bridge — disposal & HMR safety', () => { // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = harness.ctx.agents.create({ - agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' }, + agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, }) const handleB = harness.ctx.agents.create({ - agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' }, + agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, }) - expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent) - expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) + expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) await handleA.dispose() // A is gone — unregistered AND its session removed from the store. - expect(harness.ctx.agents.get('sib-a')).toBeUndefined() - expect(harness.ctx.sessions.get('sib-a')).toBeUndefined() + expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') // B is wholly unaffected. - expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent) - expect(harness.ctx.sessions.get('sib-b')).toBeDefined() + expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() }) @@ -261,16 +262,16 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = harness.ctx.agents.create({ - agentId: 'guard-a', sessionId: 'guard-a', agentOptions: { model: 'mock' }, + agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() - expect(harness.ctx.sessions.get('guard-a')).toBeDefined() + expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() - expect(harness.ctx.agents.get('guard-a')).toBeUndefined() - expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran + expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) @@ -282,7 +283,7 @@ describe('acp bridge — disposal & HMR safety', () => { // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = harness.ctx.agents.create({ - agentId: 'conc-a', sessionId: 'conc-a', agentOptions: { model: 'mock' }, + agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) // Drive a turn that hangs in the model stream, so the loop is mid-turn when // disposed — its exit runs a final session/flush we can gate to hold the @@ -312,8 +313,8 @@ describe('acp bridge — disposal & HMR safety', () => { // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) - expect(harness.ctx.agents.get('conc-a')).toBeUndefined() - expect(harness.ctx.sessions.get('conc-a')).toBeUndefined() + expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() + expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined() await harness.dispose() }) }) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index b9e2377908..69c935139d 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' describe('acp bridge — demux & config edges', () => { @@ -25,7 +27,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } }) + const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index b1e4acfda5..d254ee8885 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' +import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -155,7 +156,7 @@ describe('acp bridge — session/load replay', () => { release() // resume() finishes AFTER teardown expect(await loadResult).toBe('rejected') // No live agent was installed for the closed connection. - expect(loader.ctx.agents.get(sessionId)).toBeUndefined() + expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -176,11 +177,11 @@ describe('acp bridge — session/load replay', () => { await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/cwd mismatch/) - expect(loader.ctx.agents.get('elsewhere')).toBeUndefined() + expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -215,7 +216,7 @@ describe('acp bridge — session/load replay', () => { // Rejected BEFORE resume (metadata-only check) — no agent was registered, so // the id is not wedged: a later attempt hits the same clean rejection, not a // duplicate-registration error. - expect(loader.ctx.agents.get('legacy')).toBeUndefined() + expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined() await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/no absolute persisted cwd/) }) diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index 1c20d2ba39..ca11934046 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Text of the agent_message_chunk updates scoped to one session id. */ @@ -101,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId - const agentA = harness.ctx.agents.get(a)! - const agentB = harness.ctx.agents.get(b)! + const agentA = harness.ctx.agents.get(AgentId(a))! + const agentB = harness.ctx.agents.get(AgentId(b))! // Wait deterministically for BOTH agents to enter `running` (not a fixed // sleep — agent startup latency is unbounded on a loaded worker). diff --git a/packages/ui/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts index 5364c02d7b..3dcb4c760f 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' import { streamSessionEventUpdate } from '../src/index.ts' @@ -85,7 +85,7 @@ function actionsToEvents(actions: Action[]): SessionEvent[] { function runStream(events: SessionEvent[]): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update)) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update)) return out } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cdba5a3bf3..737ad3804a 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionNotification } from '@agentclientprotocol/sdk' import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools' import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts' @@ -8,14 +8,14 @@ import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/in /** Collect the updates a single event produces (no presenter → generic fallback). */ function updatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - streamSessionEventUpdate('s1', event, n => out.push(n.update)) + streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update)) return out } /** Collect the updates emitted by the live prompt stream (user echo suppressed). */ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false }) + streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false }) return out } @@ -138,7 +138,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) return out } @@ -324,7 +324,7 @@ describe('terminal-card mapping (capability-gated)', () => { function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] { const presenter = new ToolPresenter(registryOf(tool)) const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd }) + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd }) return out } diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 014dfecf91..7634602a35 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -283,7 +284,7 @@ describe('acp bridge — turn outcomes', () => { // OWN turn with the real model answer. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle // inject writes turn/start{injection} → context/message → turn/end). Fire // once so it lands between install and the prompt turn. @@ -339,7 +340,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - const agent = harness.ctx.agents.get(sessionId)! + const agent = harness.ctx.agents.get(AgentId(sessionId))! await agent.whenIdle() // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so // no second turn was batched or leaked. (A best-effort abort that left queued diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md new file mode 100644 index 0000000000..8f7943def7 --- /dev/null +++ b/packages/util/brand/README.md @@ -0,0 +1,26 @@ +# dsh-brand + +The `Branded` nominal-typing primitive — a tiny, **type-only** package (no runtime code, no harness-package dependency) shared by every package that owns a cross-boundary id. + +## What `Branded` is + +A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +export type SessionId = Branded<'SessionId'> + +/** Brand a string as a SessionId (a plain cast — zero runtime cost). */ +export function SessionId(id: string): SessionId { + return id as SessionId +} +``` + +Construction goes through the per-id factory in the OWNING package (a plain cast inside — zero runtime cost). Comparison, logging, JSON serialization, and the wire format all behave exactly as for an ordinary string; the brand is erased at compile time. + +## Policy: brand ids that cross package boundaries + +A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.** + +This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json new file mode 100644 index 0000000000..f0dcf7a8d7 --- /dev/null +++ b/packages/util/brand/package.json @@ -0,0 +1,28 @@ +{ + "name": "@deepseek-ai/dsh-brand", + "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts new file mode 100644 index 0000000000..051ced94b7 --- /dev/null +++ b/packages/util/brand/src/index.ts @@ -0,0 +1,27 @@ +/** + * The `Branded` nominal-typing primitive — a type-only utility (no runtime + * code, no harness-package dependency) shared by every package that owns a + * cross-boundary id. + * + * A brand makes structurally-identical strings non-interchangeable at the type + * level: an `AgentId` cannot be passed where a `CallId` is expected, even + * though both are plain strings at runtime. Construction goes through a per-id + * factory in the OWNING package (a plain cast inside — zero runtime cost); + * comparison, logging, and serialization all behave as ordinary strings. + * + * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call + * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent, + * `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package + * boundaries and could plausibly be confused; not every string needs a brand. + * This package owns ONLY the primitive — no concrete id, no runtime code beyond + * the (erased) type — so the brand vocabulary stays dependency-free and a + * package can brand its ids without depending on an unrelated capability + * package (e.g. dsh-bash brands its ids without pulling in dsh-llm). + * + * @module @deepseek-ai/dsh-brand + */ + +declare const BRAND: unique symbol + +/** A string carrying a compile-time-only brand `B`. */ +export type Branded = string & { readonly [BRAND]: B } diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json new file mode 100644 index 0000000000..f8fc535ab7 --- /dev/null +++ b/packages/util/brand/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e79f0d6ff..2d0f6ec270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: packages/bash/bash: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand 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) @@ -117,6 +120,9 @@ importers: packages/core/agent: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -163,6 +169,9 @@ importers: packages/core/session: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -196,6 +205,9 @@ importers: packages/llm/llm: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand 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) @@ -365,6 +377,12 @@ 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/util/brand: + devDependencies: + 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) + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 975b09aa54..f46c4fca6b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,7 +1,7 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/llm/src/brand.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 04b65eb3a3..8e2070fe28 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,7 @@ "./packages/bash/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", + "./packages/util/*/src", "./packages/support/*/src" ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index 6d353c1796..c9b377ab0d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "./vendor/timer" }, { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, + { "path": "./packages/util/brand" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/session-persistence/session-persistence" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index a2b2358a09..54769bdbc0 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -22,6 +22,7 @@ "./packages/bash/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", + "./packages/util/*/src", "./packages/support/*/src" ] } From f6bd1468f219be94c187e15ddb5f2de5419f9c9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:50:39 +0800 Subject: [PATCH 65/87] simplify(agent): drop the unused public Agent.abort(), keep whenIdle() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public Agent handle exposed abort() (step-only) and cancel() (queue-aware). No production caller used abort() — ACP maps session/cancel to cancel(), and lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths abort their per-step AbortController directly. So abort() is latent generality that keeps a private loop mechanic public. RFC-premise correction: the public-agent-stop-surface RFC proposed removing whenIdle() too. Implementation found whenIdle() load-bearing — a real quiescence primitive with a deliberate loop contract (settle-without-transition, the replacement-turn race) and ACP test consumers; its proposed replacement ("observe the running->idle transition") is exactly the async-state race AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC is amended on the way to implemented/ to record the narrowed scope, and the new AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its worked example. - Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg 'aborted' default goes with it (cancel() keeps its 'cancelled' default). - Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes tests whose subject is the in-flight step's AbortController drive that controller directly via the private currentAbort field (cancel() would clear the inbox and destroy the queued steering one of them proves survives a step abort). The no-arg-default test is dropped (cancel()'s default is already covered in cancel.spec.ts). - Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop READMEs, architecture.md, core.md type-equiv, the extension cookbook, the lifecycle RFC (short note), and the proposed ACP RFC. Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md --- docs/architecture.md | 4 +-- docs/cookbook/extension-cookbook.md | 4 +-- docs/cordis-catalog/events-and-services.md | 34 +++++++++---------- docs/core-data-structures/core.md | 16 ++++----- ...-18-agent-lifecycle-and-ownership-seams.md | 2 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 6 +--- packages/core/agent-loop/src/loop.ts | 8 ++--- packages/core/agent-loop/tests/agent.spec.ts | 18 +--------- packages/core/agent-loop/tests/loop.spec.ts | 6 ++-- .../agent-loop/tests/review-fixes.spec.ts | 15 ++++++-- packages/core/agent/README.md | 5 ++- packages/core/agent/src/types.ts | 16 ++++----- packages/core/agent/tests/agent.spec.ts | 1 - 15 files changed, 58 insertions(+), 81 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7c7e891a78..d853239c06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `abort(reason)` — aborts the in-flight step via `AbortSignal` -- `cancel(reason)` — the broad cancel: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. `abort()` is the narrower step-only verb; `cancel()` is what a UI/ACP `session/cancel` maps to. +- `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. - `session`, `status`, `options` @@ -159,7 +159,7 @@ forever: emit agent/status(idle) unless more queued ``` -Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with an `error` event (appended INSIDE the turn, before `turn/end`) — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. `abort()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. +Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with an `error` event (appended INSIDE the turn, before `turn/end`) — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1db79f627b..41fc85be0e 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.abort()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (`await agent.whenIdle()` after `abort()`), not just request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (handle disposal aborts in-flight work then `await`s `agent.whenIdle()`), not just request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. @@ -77,7 +77,7 @@ export function apply(ctx: Context) { } }) // Inbound "prompt": create/resume an agent and feed it; settle on turn end. - // Disposal awaits quiescence: agent.abort() then await agent.whenIdle(). + // Disposal awaits quiescence: handle disposal aborts, then await agent.whenIdle(). } ``` diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index de5cf6ad72..a07711d89d 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:141`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:136`](../../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:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:142`](../../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:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent +create(id: string, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -327,9 +327,7 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask -abstract get(id: BashTaskId): BashTask | undefined abstract ownerOf(id: BashTaskId): OwnerToken | undefined -abstract list(): BashTask[] abstract readOutput(id: BashTaskId): BashTaskRead abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 46b416724e..ae6086b7b7 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -233,12 +233,8 @@ interface Agent { */ inject(content: ContentBlock[], options?: SendOptions): void - /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ - abort(reason?: string): void - /** - * Cancel ALL pending work for the agent — the narrower {@link abort} kills - * only the in-flight step. `cancel()`: + * Cancel ALL pending work for the agent. `cancel()`: * * - clears the queued FIFO (un-started prompts never run) and the steering * FIFO (steering for the cancelled turn is dropped, not re-enqueued); @@ -258,11 +254,11 @@ interface Agent { /** * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: `agent.abort()` then - * `await agent.whenIdle()` guarantees queued/running work has fully stopped - * before the caller proceeds (a closing ACP connection, a disposing UI - * plugin), rather than returning while the driver is still streaming or about - * to start a queued turn. + * quiescence signal a teardown awaits: a lifecycle owner disposes the agent + * through its `AgentHandle` (which aborts in-flight work then awaits this), so + * the caller proceeds only after queued/running work has fully stopped (a + * closing ACP connection, a disposing UI plugin) rather than returning while + * the driver is still streaming or about to start a queued turn. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index dc4b2428a1..35cb2eb6b3 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,7 +12,7 @@ The three seams shipped across a stacked chain of PRs (the queue-aware cancel, t ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface (distinct from the narrower step-only `abort()`). It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index f133cc1d82..e3af4abb3c 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.abort()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.cancel()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c7ea092c72..4892b357bf 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -68,7 +68,7 @@ forever: Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. -Cancellation: `agent.abort()` aborts only the in-flight step; `agent.cancel()` is the broad verb — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. +Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) ### What is NOT here diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index df25af1c11..12f15868dc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -191,10 +191,6 @@ export class ReactLoopAgent implements Agent { } } - abort(reason?: string): void { - this.currentAbort?.abort(reason ?? 'aborted') - } - cancel(reason?: string): void { // Arm-gate: only mark a cancellation when there is actually work to cancel — // a running turn, an in-flight step, or queued/steering work. An idle cancel @@ -233,7 +229,7 @@ export class ReactLoopAgent implements Agent { * running→idle/disposed transition, resolving on `idle` directly (the turn * fully ended) or chaining {@link done} on `disposed` (wait for the loop to * actually exit). Implements the {@link Agent.whenIdle} contract used by - * teardown (`abort()` then `await whenIdle()`). + * teardown (handle disposal aborts in-flight work, then awaits `whenIdle()`). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b8f43a0a9b..b5d7aca146 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -435,7 +435,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, if (handle.isDisposed()) { reason = { kind: 'disposed' } } else if (abort.signal.aborted) { - /* v8 ignore next -- abort.signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } } else { failTurn(error) @@ -590,7 +590,7 @@ async function runStep( // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(request)) { - /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) session.append('assistant/chunk', { turn, step, chunk }) ctx.emit('agent/stream-chunk', agent, turn, step, chunk) @@ -633,7 +633,7 @@ async function runStep( // isError results, so abort is re-checked around every call here. const toolCalls = message.content.filter(block => block.type === 'tool-call') for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) let parsedArguments: unknown @@ -664,7 +664,7 @@ async function runStep( }) // signal CAN flip during the await above (abort() inside a tool); // the analyzer can't see through the await boundary. - /* v8 ignore start -- signal.reason default unreachable via agent.abort() */ + /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) /* v8 ignore stop */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d9235cfef3..ed163bf4c2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -288,7 +288,7 @@ describe('ReactLoopAgent', () => { expect(settled).toBe(false) await waitForStatus(ctx, agent, 'running') - agent.abort('done') + agent.cancel('done') await idle expect(settled).toBe(true) expect(agent.status).toBe('idle') @@ -430,20 +430,4 @@ describe('ReactLoopAgent', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle')) warn.mockRestore() }) - - it('abort() resolves reason to "aborted" when no reason provided', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - - const reasons: { kind: string; reason?: string }[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) - - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - agent.abort() // no reason string - await waitForIdle(ctx, agent) - - expect(reasons[0]).toMatchObject({ kind: 'aborted', reason: 'aborted' }) - }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index cd912f39cd..18dbafeb04 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -318,7 +318,7 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) - it('abort() mid-stream ends the turn with reason aborted', async () => { + it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -327,10 +327,10 @@ describe('agent loop', () => { ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) send(agent, 'go') - // wait until the stream is hanging, then abort + // wait until the stream is hanging, then cancel await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - agent.abort('user interrupt') + agent.cancel('user interrupt') await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index fa6a560c7f..0d2441c7db 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -92,7 +92,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) describe('HIGH: abort during tool execution ends the turn', () => { - it('abort() inside a tool prevents both remaining tools and the next model step', async () => { + it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step [ @@ -113,7 +113,11 @@ describe('HIGH: abort during tool execution ends the turn', () => { parameters: {}, async execute() { executed.push('aborter') - agent.abort('user interrupt') + // Fire the in-flight step's AbortController directly (the loop registers + // it on the agent). This is the bare step-abort path — distinct from + // cancel(), which would also clear the inbox; here the subject is the + // loop's response to its running step being aborted mid-tool. + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') return [{ type: 'text', text: 'done' }] }, })) @@ -228,7 +232,12 @@ describe('HIGH: steering from late extension points is never stranded', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) agent.steer([{ type: 'text', text: 'redirect' }]) - agent.abort('user interrupt') + // Abort ONLY the in-flight step, via its AbortController directly — NOT + // cancel(), which clears the inbox and would drop the queued steering this + // test proves survives a step abort. There is no public step-only abort + // verb (cancel() is the only public stop primitive), so reach the private + // controller the loop registered. + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') await waitForIdle(ctx, agent) // a new turn ran with the steering content delivered as a message diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8e9d41855c..28a8cd0cf0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,9 +56,8 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) -- `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) -- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. +- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. +- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent. - `agent.session`, `agent.status`, `agent.options`, `agent.id` ### Extension points diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index ef20bf2705..be0394adc5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -79,12 +79,8 @@ export interface Agent { */ inject(content: ContentBlock[], options?: SendOptions): void - /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ - abort(reason?: string): void - /** - * Cancel ALL pending work for the agent — the narrower {@link abort} kills - * only the in-flight step. `cancel()`: + * Cancel ALL pending work for the agent. `cancel()`: * * - clears the queued FIFO (un-started prompts never run) and the steering * FIFO (steering for the cancelled turn is dropped, not re-enqueued); @@ -104,11 +100,11 @@ export interface Agent { /** * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: `agent.abort()` then - * `await agent.whenIdle()` guarantees queued/running work has fully stopped - * before the caller proceeds (a closing ACP connection, a disposing UI - * plugin), rather than returning while the driver is still streaming or about - * to start a queued turn. + * quiescence signal a teardown awaits: a lifecycle owner disposes the agent + * through its `AgentHandle` (which aborts in-flight work then awaits this), so + * the caller proceeds only after queued/running work has fully stopped (a + * closing ACP connection, a disposing UI plugin) rather than returning while + * the driver is still streaming or about to start a queued turn. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index ff952aee4f..c344cd2a6f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -13,7 +13,6 @@ function stubAgent(rawId: string): Agent { send() {}, steer() {}, inject() {}, - abort() {}, cancel() {}, whenIdle() { return Promise.resolve() }, } From c6ed980d6f08d2b90efc90ee158c61ace42e4497 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:10:56 +0800 Subject: [PATCH 66/87] fix review findings: stale abort() docs + move RFC to implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's no-ship was a completeness/docs-sync gap, not loop behavior: - docs/architecture.md: drop the public abort() handle row; the teardown signal is now cancel() then await whenIdle(). - cancel.spec.ts: the module doc and the turn-start comment contrasted cancel() against a public abort() verb that no longer exists — reword to name the loop's private step AbortController. - packages/ui/acp/src/index.ts: the post-resume-leak comment cited abort(); cancel() is the surviving stop verb that likewise does not unregister. - Move the RFC proposed -> implemented/simplification with amended text: Status flips, the both-removal proposal is narrowed to abort-only, and an implementation note records why whenIdle() is retained (load-bearing quiescence primitive with live ACP consumers). Update docs/rfc/README.md. - AGENTS.md "RFCs are proposals, not golden truth": add the concrete abort/whenIdle worked example now that the implemented RFC exists to link. - Regenerate the cordis catalog (line-number drift from the rebase). --- AGENTS.md | 2 ++ docs/architecture.md | 3 +- docs/cordis-catalog/events-and-services.md | 34 +++++++++--------- docs/rfc/README.md | 2 +- .../2026-06-20-public-agent-stop-surface.md | 36 +++++++++++++++++++ .../2026-06-20-public-agent-stop-surface.md | 32 ----------------- packages/core/agent-loop/tests/cancel.spec.ts | 5 +-- packages/ui/acp/src/index.ts | 2 +- 8 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md diff --git a/AGENTS.md b/AGENTS.md index dc4e65a962..2fdd25b552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ The same discipline applies one level up, to the RFCs in `docs/rfc/`. A **propos When carrying out the change fights back — a removal forces an awkward migration, deletes machinery that turns out to be load-bearing, or pushes consumers onto a more brittle hand-rolled equivalent — treat that friction as **evidence the RFC over-reached**, not as work to push through. Keep, split, or amend the change to match what the code actually wants, and say so in the PR. An RFC that ships in amended form gets its text amended on the way to `implemented/`, so the landed RFC describes what actually shipped rather than the original guess. The discipline cuts both ways: an RFC is also not a reason to *avoid* a change a maintainer would otherwise make — it is one input, weighed against the code in front of you. +The worked example is [Keep one public stop primitive](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md): it proposed removing BOTH `Agent.abort()` and `Agent.whenIdle()` as redundant stop/quiescence surface. Validating against the code, `abort()` was genuinely dead — no production caller, the loop aborts its own `AbortController` directly — so it was removed as proposed. But `whenIdle()` was load-bearing: a deliberate quiescence primitive with live ACP consumers, and the RFC's suggested migration (observe the `running`→`idle` transition by hand) is exactly the brittle path § Defensive patterns warns against ("Async state is not synchronous state"). So only `abort()` shipped, `whenIdle()` stayed, and the RFC's text was amended on the way to `implemented/` to record the narrowed scope — the landed RFC is not a lie about what was built. + ## Architecture This codebase is based on the **Cordis** framework, built microkernel-style: **everything is a plugin**. All necessary Cordis dependencies are copied into this monorepo as vendored source (under `vendor/`) instead of being depended on via npm. diff --git a/docs/architecture.md b/docs/architecture.md index d853239c06..c7c84ce3b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,9 +112,8 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `send(content)` — queued message; starts a turn when idle, else next turn - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `abort(reason)` — aborts the in-flight step via `AbortSignal` - `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. -- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. +- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `cancel()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. - `session`, `status`, `options` **TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a07711d89d..afa4356880 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:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: string, options: AgentOptions = {}): ReactLoopAgent +create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -327,7 +327,9 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask +abstract get(id: BashTaskId): BashTask | undefined abstract ownerOf(id: BashTaskId): OwnerToken | undefined +abstract list(): BashTask[] abstract readOutput(id: BashTaskId): BashTaskRead abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void diff --git a/docs/rfc/README.md b/docs/rfc/README.md index eaefbdda94..fae21e0351 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -51,7 +51,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 | | [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | -| [Keep one public stop primitive](proposed/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | ### Architecture @@ -95,6 +94,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.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 | | [Prune dead methods from the persistence and bash seams](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 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md new file mode 100644 index 0000000000..a737acbbd0 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -0,0 +1,36 @@ +# RFC: Keep one public stop primitive + +Status: implemented (proposed 2026-06-20; accepted in amended form — `whenIdle()` retained) + +> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [AGENTS.md § Defensive patterns](../../../../AGENTS.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped. + +## Problem + +The public `Agent` handle exposed two overlapping ways to stop in-flight work: `abort(reason?)` and `cancel(reason?)`. `abort()` killed only the in-flight step and left queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needed bare `abort()`. + +The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code called the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that called `abort()` interrupt an empty queue and switch to `cancel(reason)`; the steering re-delivery test that deliberately depends on queue preservation drives the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default. + +The extra surface area made the loop carry a public verb that is mostly a teardown internal: `abort()` had to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. + +## Proposal + +Keep `cancel()` as the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract. + +`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent, and it has live consumers (the ACP bridge's settle points). + +Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop. + +## Acceptance criteria + +- `Agent` exposes no public `abort()`; `cancel()`, `whenIdle()`, and `steer()` remain part of the surface. +- ACP cancellation continues to call `cancel()`. +- Agent teardown continues to await quiescence through handle disposal, and `whenIdle()` still resolves on quiescence for non-owner observers. +- Tests cover cancellation and disposal as the two supported stop paths. + +## What we give up + +A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps a private loop mechanic public. + +## Related + +This RFC only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, `whenIdle()`, status, options, session, and identity. diff --git a/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md deleted file mode 100644 index 6c67413a49..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md +++ /dev/null @@ -1,32 +0,0 @@ -# RFC: Keep one public stop primitive - -Status: proposed - -## Problem - -The public `Agent` handle exposes three ways to reason about stopping work: `abort(reason?)`, `cancel(reason?)`, and `whenIdle()`. `abort()` kills only the in-flight step and leaves queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race; `whenIdle()` exposes the loop's private quiescence waiter to any consumer. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs bare `abort()` or `whenIdle()`. - -The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code calls the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that call `abort()` interrupt an empty queue and can switch to `cancel(reason)`; the one steering re-delivery test that deliberately depends on queue preservation should drive the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is also deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default. - -The extra surface area makes the loop carry public semantics that are mostly teardown internals. `whenIdle()` needs waiter state, special disposed-agent behavior, and a loop-exit promise so it resolves after quiescence rather than merely after a status flip. `abort()` has to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. - -## Proposal - -Keep `cancel()` as the only public stop primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation can keep private abort controllers and quiescence promises, but they are not part of the plugin-facing `Agent` contract. - -Delete public `abort()` and `whenIdle()`, the tests that exercise them as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop; that guarantee moves entirely onto `AgentHandle.dispose()`. - -## Acceptance criteria - -- `Agent` exposes no public `abort()` or `whenIdle()`; `steer()` remains part of the message surface. -- ACP cancellation continues to call `cancel()`. -- Agent teardown continues to await quiescence through handle disposal. -- Tests cover cancellation and disposal as the two supported stop paths. - -## What we give up - -A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps private loop mechanics public. - -## Related - -This RFC only removes the stop/quiescence methods. Mid-turn steering remains an intentional message path; the resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, status, options, session, and identity. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4a417dbdce..9cdaa1973b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,7 +1,8 @@ /** * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the * broad verb — it clears queued + steering work, aborts an in-flight step, and - * drops a turn about to start — whereas `abort()` kills only the current step. + * drops a turn about to start — whereas a bare step abort (the loop's private + * `AbortController`) kills only the current step and leaves the queue intact. * These tests exercise every window where a cancel can land (idle, pre-step, * mid-step, continuation) and the marker's arm/reset rules that keep a cancel * from leaking to a later prompt or hanging `whenIdle()`. @@ -172,7 +173,7 @@ describe('Agent.cancel()', () => { // A turn-start listener fires BEFORE any AbortController is installed for the // step. Cancelling there must still drop the step (the turn-scoped marker, - // not abort(), is what catches this) — no model step runs. + // not the step AbortController, is what catches this) — no model step runs. let streamed = false ctx.on('agent/stream-chunk', () => { streamed = true }) const dispose = ctx.on('agent/turn-start', (subject) => { diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 2964726b94..d7662cfebf 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -488,7 +488,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // Validate the PERSISTED cwd BEFORE resuming — `list()` is a // metadata-only read (no full-log parse), so this rejects a session we // can't honor WITHOUT ever constructing/registering an agent (a - // post-resume reject would leak the registered agent — abort() does not + // post-resume reject would leak the registered agent — cancel() does not // unregister it — and wedge the id against re-load). The session's bash // workdir is derived from its persisted `header.cwd` and the request // `cwd` does NOT override it (resume takes no cwd), so a session with no From 436305b1c267094aaa4b73ea3bcf20993368e8f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:37:52 +0800 Subject: [PATCH 67/87] fix review findings: correct teardown framing (dispose, not cancel+whenIdle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's second pass caught that the prior doc fix swapped one wrong primitive for another: framing teardown as cancel()+whenIdle() (or awaiting agent.whenIdle() on disposal) is still wrong. whenIdle() only OBSERVES quiescence; cancel() only stops queued/in-flight work. Neither unregisters the agent or detaches the session. Real teardown is AgentHandle.dispose(), whose disposer does `stop(); await agent.done` — stop the loop, await its exit, and unregister (packages/core/agent-loop/src/index.ts:271). Copying the old framing would reintroduce the orphaned-agent/session leak the AgentHandle seam exists to prevent. - docs/architecture.md: whenIdle() is a non-owner quiescence-observation hook, explicitly NOT teardown; teardown is `await AgentHandle.dispose()`. - docs/cookbook/extension-cookbook.md (prose + the ts comment): tear agents down via AgentHandle.dispose(), not agent.whenIdle(). - docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md: the lifecycle/disposal paragraph routes teardown through the handle's dispose(). --- docs/architecture.md | 2 +- docs/cookbook/extension-cookbook.md | 4 ++-- .../proposed/feature/2026-06-14-acp-agent-client-protocol.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c7c84ce3b4..a24f4bdcd0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. -- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `cancel()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. +- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` **TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 41fc85be0e..6c4498e254 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (handle disposal aborts in-flight work then `await`s `agent.whenIdle()`), not just request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. @@ -77,7 +77,7 @@ export function apply(ctx: Context) { } }) // Inbound "prompt": create/resume an agent and feed it; settle on turn end. - // Disposal awaits quiescence: handle disposal aborts, then await agent.whenIdle(). + // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index e3af4abb3c..8244cab354 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.cancel()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Disposal must come through the `dsh-agent` handle seam, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so a bridge that wanted to wait on quiescence directly would instead observe `agent/status` reaching `idle`/`disposed` — but routing teardown through the handle's `dispose()` makes that unnecessary. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. From 2be60b9a2287a362803f2e82af1147dc289a02ba Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:00:06 +0800 Subject: [PATCH 68/87] simplify(session): fold trace-only usage/error events into load-bearing events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements. --- docs/architecture.md | 8 +- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session.md | 23 ++- docs/rfc/README.md | 2 +- ...6-20-collapse-trace-only-session-events.md | 11 +- .../testing/2026-06-19-acp-snapshot-tests.md | 4 +- .../error-finish/session.golden.jsonl | 3 +- .../snapshots/multi-turn/session.golden.jsonl | 72 ++++----- .../tests/snapshots/multi-turn/session.jsonl | 72 ++++----- .../snapshots/text-turn/session.golden.jsonl | 7 +- .../tests/snapshots/text-turn/session.jsonl | 7 +- .../tool-call-turn/session.golden.jsonl | 84 +++++----- .../snapshots/tool-call-turn/session.jsonl | 84 +++++----- .../workspace-edit/session.golden.jsonl | 151 +++++++++--------- .../snapshots/workspace-edit/session.jsonl | 151 +++++++++--------- packages/core/agent-loop/src/loop.ts | 61 +++---- .../agent-loop/tests/coverage-edges.spec.ts | 20 +-- packages/core/agent-loop/tests/loop.spec.ts | 52 +++++- .../agent-loop/tests/review-fixes.spec.ts | 75 +++------ packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 9 +- packages/core/session/src/types.ts | 19 ++- .../core/session/tests/properties.spec.ts | 3 +- packages/support/invariants/src/index.ts | 4 +- .../invariants/tests/invariants.spec.ts | 10 +- .../llm-replay/tests/llm-replay.spec.ts | 4 +- packages/ui/acp/src/index.ts | 4 +- packages/ui/acp/tests/codec.spec.ts | 2 +- packages/ui/acp/tests/stream-update.spec.ts | 2 +- 30 files changed, 480 insertions(+), 470 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-06-20-collapse-trace-only-session-events.md (68%) diff --git a/docs/architecture.md b/docs/architecture.md index c7c84ce3b4..891e851337 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta A `Session` is an append-only log of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`): - `user/message` → user message -- `assistant/message` → assistant message (raw `assistant/chunk` events are replay/UI data and are skipped in derivation) +- `assistant/message` → assistant message (raw `assistant/chunk` events are replay/UI data and are skipped in derivation; an empty-content `assistant/message`, which exists only to host a max-tokens step's `usage`, is skipped too) - `tool/result` → user message carrying a `tool-result` block - `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (``) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: the real adapters now exist (the original precondition); the envelope still wants a deliberate review against live model behavior (`TODO(review)` in dsh-session). @@ -142,7 +142,7 @@ forever: step error (turn ends error/aborted, not a normal completed message) msg = waterfall agent/step-result ⟵ runs BEFORE the log append, so the - session('assistant/message', 'usage') log records what tool dispatch uses + session('assistant/message' {content, usage?}) log records what tool dispatch uses each tool-call (sequential, abort-checked between calls): session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute session('tool/result') @@ -158,11 +158,11 @@ forever: emit agent/status(idle) unless more queued ``` -Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with an `error` event (appended INSIDE the turn, before `turn/end`) — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. +Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with `turn/end { reason: { kind: 'error', step, message, code? } }` — the failure's step number rides on the durable turn reason (there is no separate session `error` event); live diagnostics fire via `agent/error`. Never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. -A failure that happens once the turn is already closed has no in-turn position for a session `error` event (appending one after `turn/end` would put it past the persistence commit boundary, where it is dropped as a crash tail — [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. +A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. **Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index afa4356880..fbb2ab10ba 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -390,7 +390,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:222`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:229`](../../packages/core/session/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ae6086b7b7..ec4805c798 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -189,7 +189,7 @@ type SessionEvent = { }[T] ``` -The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `usage`, `error`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The eleven event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d60b738b6a..d73ad0cb4e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -24,14 +24,17 @@ interface SessionEventMap { 'context/message': { content: ContentBlock[]; source: MessageSource } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } - /** Assembled assistant message for one step (derived history uses this). */ - 'assistant/message': { turn: number; step: number; content: ContentBlock[] } + /** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - 'usage': { turn: number; step: number; usage: TokenUsage } - 'error': { turn: number; step: number; message: string; code?: string } } ``` @@ -59,11 +62,11 @@ type SessionEvent = { `Session.deriveMessages()` projects the event log into the `Message[]` the model sees. The projection rules: - `user/message` → a user message. -- `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). +- `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its `usage`, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. - `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (``) at their chronological position — the "system-reminder" pattern; the model distinguishes them from real prompts by the envelope. -Everything else (`turn/*`, `step/*`, `usage`, `error`) is structural/telemetry and does not project into a message. +Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. ## What started a turn: `TurnTriggerMap` @@ -89,7 +92,13 @@ interface TurnTriggerMap { interface TurnEndReasonMap { completed: { kind: 'completed' } aborted: { kind: 'aborted'; reason?: string } - error: { kind: 'error'; message: string; code?: string } + /** + * The turn failed: a step threw or the model reported a failure. `step` is the + * step number the failure occurred on (the operational error's location — the + * single durable record of an in-turn failure; live diagnostics also fire via + * `agent/error`). `code` is the error's code when one was attached. + */ + error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** diff --git a/docs/rfc/README.md b/docs/rfc/README.md index fae21e0351..e37f162164 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -51,7 +51,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 | | [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | -| [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | ### Architecture @@ -95,6 +94,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | | [Prune dead methods from the persistence and bash seams](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 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md similarity index 68% rename from docs/rfc/proposed/simplification/2026-06-20-collapse-trace-only-session-events.md rename to docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index ecba30bcbd..3a2c11bdc6 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -1,6 +1,6 @@ # RFC: Fold trace-only session facts into load-bearing events -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem @@ -31,3 +31,12 @@ If analytics become real, add a projection helper or a dedicated telemetry store ## What we give up A consumer can no longer filter the canonical log for standalone `usage` or step-level `error` rows. It must read those facts from the assistant/failure events that carry them. That is a reasonable simplification only if the implementing PR proves the same facts remain present; otherwise the standalone events should stay. + +## Implementation note + +Shipped as proposed, with two scope refinements (per AGENTS.md "RFCs are proposals, not golden truth"): + +- **No format-version bump.** The acceptance criterion "the session format version and recorded fixtures are refreshed" over-reached: the harness is pre-release with no persisted user data, so per the pre-release format policy there is nothing to migrate or reject. The session `version` stays `1`; only event shapes and recorded fixtures change. `turn/end.reason.error.step` is therefore optional-on-read for any hypothetical pre-existing log but guaranteed for newly-written ones — no migration shim. +- **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. + +Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. 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 af8c40c55e..a5875993a3 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 @@ -18,7 +18,7 @@ A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it ov ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message`/`usage` events carry the harness's behavior. One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). +The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message` events carry the harness's behavior (token usage rides on `assistant/message.usage`). One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. @@ -55,7 +55,7 @@ A snapshot run asserts **two** normalized goldens, because the harness's externa 1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). 2. The **re-derived session JSONL** — the log the replay run itself persists, compared against the recorded fixture. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. -The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. +The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `assistant/message.usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the snapshot: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The goldens are themselves **JSONL** — one compact, normalized record per line, in the same shape as the surfaces they mirror (NDJSON on the wire, JSONL on disk: `stdout.golden.jsonl`, `session.golden.jsonl`), so they stay `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the golden store and the `-u`/`--update` "accept the diff" workflow. diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl index fcf2cde49f..9f6ee27674 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl @@ -3,5 +3,4 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} {"type":"step/end","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"error","seq":4,"time":0,"data":{"turn":1,"step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}} -{"type":"turn/end","seq":5,"time":0,"data":{"turn":1,"reason":{"kind":"error","message":"simulated provider error (HTTP 401)","code":"AUTH"}}} +{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl index 20fe3a727b..92f0465e66 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl @@ -27,40 +27,38 @@ {"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} {"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}}} {"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}]}} -{"type":"usage","seq":29,"time":0,"data":{"turn":1,"step":1,"usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":32,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":33,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":34,"time":0,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} -{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":61,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}]}} -{"type":"usage","seq":62,"time":0,"data":{"turn":2,"step":1,"usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":63,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":64,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}} +{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":31,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":32,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":33,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} +{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}} +{"type":"step/end","seq":61,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":62,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index c490a17f38..5d56942463 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -27,40 +27,38 @@ {"type":"assistant/chunk","seq":25,"time":1781834689008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} {"type":"assistant/chunk","seq":26,"time":1781834689008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}}} {"type":"assistant/chunk","seq":27,"time":1781834689008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1781834689009,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}]}} -{"type":"usage","seq":29,"time":1781834689010,"data":{"turn":1,"step":1,"usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":30,"time":1781834689010,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":31,"time":1781834689010,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":32,"time":1781834689017,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":33,"time":1781834689017,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":34,"time":1781834689017,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":35,"time":1781834689551,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":36,"time":1781834689551,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":37,"time":1781834689643,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":38,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":39,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":40,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":41,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":42,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":43,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":44,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":45,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":46,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":47,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} -{"type":"assistant/chunk","seq":48,"time":1781834689703,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":49,"time":1781834689731,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":50,"time":1781834689731,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":51,"time":1781834689732,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":52,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":53,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":54,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":55,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} -{"type":"assistant/chunk","seq":56,"time":1781834689760,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":57,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} -{"type":"assistant/chunk","seq":58,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} -{"type":"assistant/chunk","seq":59,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":60,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":61,"time":1781834689789,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}]}} -{"type":"usage","seq":62,"time":1781834689789,"data":{"turn":2,"step":1,"usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":63,"time":1781834689789,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":64,"time":1781834689789,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":28,"time":1781834689009,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}} +{"type":"step/end","seq":29,"time":1781834689010,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":30,"time":1781834689010,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":31,"time":1781834689017,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":32,"time":1781834689017,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":33,"time":1781834689017,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":34,"time":1781834689551,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":35,"time":1781834689551,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":36,"time":1781834689643,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":37,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":38,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":39,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":40,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":41,"time":1781834689676,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":42,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":43,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":45,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":46,"time":1781834689702,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} +{"type":"assistant/chunk","seq":47,"time":1781834689703,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":48,"time":1781834689731,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1781834689731,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":50,"time":1781834689732,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":51,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":52,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":53,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":54,"time":1781834689759,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} +{"type":"assistant/chunk","seq":55,"time":1781834689760,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":56,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"type":"assistant/chunk","seq":57,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} +{"type":"assistant/chunk","seq":58,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":59,"time":1781834689788,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1781834689789,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}} +{"type":"step/end","seq":61,"time":1781834689789,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":62,"time":1781834689789,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl index ad4e11841e..9b8447bf17 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl @@ -29,7 +29,6 @@ {"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} {"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} {"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."},{"type":"text","text":"PONG"}]}} -{"type":"usage","seq":31,"time":0,"data":{"turn":1,"step":1,"usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} -{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} +{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 8306f3d4de..ed34f9a727 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -29,7 +29,6 @@ {"type":"assistant/chunk","seq":27,"time":1781834680226,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} {"type":"assistant/chunk","seq":28,"time":1781834680226,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} {"type":"assistant/chunk","seq":29,"time":1781834680226,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1781834680227,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."},{"type":"text","text":"PONG"}]}} -{"type":"usage","seq":31,"time":1781834680227,"data":{"turn":1,"step":1,"usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} -{"type":"step/end","seq":32,"time":1781834680228,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":33,"time":1781834680228,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":30,"time":1781834680227,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} +{"type":"step/end","seq":31,"time":1781834680228,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1781834680228,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl index a3ddac0862..e9e72c2494 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl @@ -62,46 +62,44 @@ {"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}}}} {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":63,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}]}} -{"type":"usage","seq":64,"time":0,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}} -{"type":"tool/call","seq":65,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} -{"type":"tool/result","seq":66,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}} -{"type":"step/end","seq":67,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":68,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"S"}}} -{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} -{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} -{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} -{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":94,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":95,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":96,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":97,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":98,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":101,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":102,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."},{"type":"text","text":"DONE"}]}} -{"type":"usage","seq":103,"time":0,"data":{"turn":1,"step":2,"usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}} -{"type":"step/end","seq":104,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":105,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":63,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}],"usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}} +{"type":"tool/call","seq":64,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} +{"type":"tool/result","seq":65,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}} +{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":67,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"S"}}} +{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} +{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} +{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} +{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} +{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":94,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":95,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":98,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":101,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}} +{"type":"step/end","seq":102,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":103,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 411c05fdc4..3566adab87 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -62,46 +62,44 @@ {"type":"assistant/chunk","seq":60,"time":1781834682119,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}}}} {"type":"assistant/chunk","seq":61,"time":1781834682119,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":62,"time":1781834682119,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":63,"time":1781834682121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}]}} -{"type":"usage","seq":64,"time":1781834682121,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}} -{"type":"tool/call","seq":65,"time":1781834682121,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} -{"type":"tool/result","seq":66,"time":1781834682136,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}} -{"type":"step/end","seq":67,"time":1781834682137,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":68,"time":1781834682137,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":69,"time":1781834682760,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":70,"time":1781834682761,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":71,"time":1781834682826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":72,"time":1781834682855,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":73,"time":1781834682885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":74,"time":1781834682886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":75,"time":1781834682886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":76,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":77,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"S"}}} -{"type":"assistant/chunk","seq":78,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} -{"type":"assistant/chunk","seq":79,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} -{"type":"assistant/chunk","seq":80,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":81,"time":1781834682916,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} -{"type":"assistant/chunk","seq":82,"time":1781834682946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":83,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":84,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":85,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":86,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":87,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":88,"time":1781834682976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":89,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":90,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":91,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":92,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":93,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":94,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":95,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":96,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":97,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":98,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":99,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":100,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":101,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":102,"time":1781834683008,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."},{"type":"text","text":"DONE"}]}} -{"type":"usage","seq":103,"time":1781834683008,"data":{"turn":1,"step":2,"usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}} -{"type":"step/end","seq":104,"time":1781834683008,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":105,"time":1781834683008,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":63,"time":1781834682121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}],"usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}} +{"type":"tool/call","seq":64,"time":1781834682121,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} +{"type":"tool/result","seq":65,"time":1781834682136,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}} +{"type":"step/end","seq":66,"time":1781834682137,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":67,"time":1781834682137,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":68,"time":1781834682760,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":69,"time":1781834682761,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":70,"time":1781834682826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":71,"time":1781834682855,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":72,"time":1781834682885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":73,"time":1781834682886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":74,"time":1781834682886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":75,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":76,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"S"}}} +{"type":"assistant/chunk","seq":77,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} +{"type":"assistant/chunk","seq":78,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} +{"type":"assistant/chunk","seq":79,"time":1781834682915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} +{"type":"assistant/chunk","seq":80,"time":1781834682916,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} +{"type":"assistant/chunk","seq":81,"time":1781834682946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":82,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":83,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":84,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":85,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":86,"time":1781834682947,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":87,"time":1781834682976,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":88,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":89,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":90,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":91,"time":1781834682977,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":92,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":93,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":94,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":95,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":1781834683007,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":98,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":99,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":100,"time":1781834683008,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":101,"time":1781834683008,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}} +{"type":"step/end","seq":102,"time":1781834683008,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":103,"time":1781834683008,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl index 4d84da80f0..4415e42f8f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl @@ -113,80 +113,77 @@ {"type":"assistant/chunk","seq":111,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}}}} {"type":"assistant/chunk","seq":112,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}}} {"type":"assistant/chunk","seq":113,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":114,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."},{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}]}} -{"type":"usage","seq":115,"time":0,"data":{"turn":1,"step":1,"usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}} -{"type":"tool/call","seq":116,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}} -{"type":"tool/result","seq":117,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","content":[{"type":"text","text":"(no output)"}],"isError":false}} -{"type":"step/end","seq":118,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":119,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":120,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":121,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"App"}}} -{"type":"assistant/chunk","seq":122,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ended"}}} -{"type":"assistant/chunk","seq":123,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":124,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":125,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":126,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":127,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":128,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":129,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":130,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":131,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":132,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":133,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":134,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":135,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":136,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":137,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"cat"}}} -{"type":"assistant/chunk","seq":139,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":140,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":141,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":142,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":143,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":144,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":145,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":146,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":147,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":148,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"Read"}}} -{"type":"assistant/chunk","seq":149,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":150,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":151,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":152,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" confirm"}}} -{"type":"assistant/chunk","seq":153,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":154,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":155,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Appended successfully. Now read the file."}}}} -{"type":"assistant/chunk","seq":156,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} -{"type":"assistant/chunk","seq":157,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}}} -{"type":"assistant/chunk","seq":158,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":159,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Appended successfully. Now read the file."},{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}]}} -{"type":"usage","seq":160,"time":0,"data":{"turn":1,"step":2,"usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}} -{"type":"tool/call","seq":161,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} -{"type":"tool/result","seq":162,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","content":[{"type":"text","text":"hello\nWORLD\n"}],"isError":false}} -{"type":"step/end","seq":163,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":164,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":165,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":166,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":167,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":168,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} -{"type":"assistant/chunk","seq":169,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} -{"type":"assistant/chunk","seq":170,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":171,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} -{"type":"assistant/chunk","seq":172,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":173,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":174,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":175,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":176,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":177,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":178,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":179,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":180,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":181,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":182,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":183,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."}}}} -{"type":"assistant/chunk","seq":184,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":185,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":186,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":187,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."},{"type":"text","text":"DONE"}]}} -{"type":"usage","seq":188,"time":0,"data":{"turn":1,"step":3,"usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}} -{"type":"step/end","seq":189,"time":0,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":190,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":114,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."},{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}],"usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}} +{"type":"tool/call","seq":115,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}} +{"type":"tool/result","seq":116,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","content":[{"type":"text","text":"(no output)"}],"isError":false}} +{"type":"step/end","seq":117,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":118,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":119,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":120,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"App"}}} +{"type":"assistant/chunk","seq":121,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ended"}}} +{"type":"assistant/chunk","seq":122,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":123,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":124,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":125,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":126,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":127,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":128,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":130,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":131,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":132,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":133,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":134,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":135,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":136,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":137,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"cat"}}} +{"type":"assistant/chunk","seq":138,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} +{"type":"assistant/chunk","seq":139,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":140,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":141,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":142,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":143,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":144,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":145,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":146,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"Read"}}} +{"type":"assistant/chunk","seq":148,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} +{"type":"assistant/chunk","seq":149,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":150,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":151,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" confirm"}}} +{"type":"assistant/chunk","seq":152,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":153,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":154,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Appended successfully. Now read the file."}}}} +{"type":"assistant/chunk","seq":155,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} +{"type":"assistant/chunk","seq":156,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}}} +{"type":"assistant/chunk","seq":157,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":158,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Appended successfully. Now read the file."},{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}],"usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}} +{"type":"tool/call","seq":159,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} +{"type":"tool/result","seq":160,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","content":[{"type":"text","text":"hello\nWORLD\n"}],"isError":false}} +{"type":"step/end","seq":161,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":162,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":163,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":164,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":165,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":166,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":167,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":168,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":169,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":170,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":171,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":172,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":173,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":174,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":175,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":176,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":177,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":178,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":179,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":180,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":181,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."}}}} +{"type":"assistant/chunk","seq":182,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":183,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":184,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":185,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}} +{"type":"step/end","seq":186,"time":0,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":187,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 3baefe4e86..b7a54cfaeb 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -113,80 +113,77 @@ {"type":"assistant/chunk","seq":111,"time":1781834685385,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}}}} {"type":"assistant/chunk","seq":112,"time":1781834685386,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}}} {"type":"assistant/chunk","seq":113,"time":1781834685386,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":114,"time":1781834685387,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."},{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}]}} -{"type":"usage","seq":115,"time":1781834685387,"data":{"turn":1,"step":1,"usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}} -{"type":"tool/call","seq":116,"time":1781834685387,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}} -{"type":"tool/result","seq":117,"time":1781834685400,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","content":[{"type":"text","text":"(no output)"}],"isError":false}} -{"type":"step/end","seq":118,"time":1781834685400,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":119,"time":1781834685400,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":120,"time":1781834686163,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":121,"time":1781834686163,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"App"}}} -{"type":"assistant/chunk","seq":122,"time":1781834686261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ended"}}} -{"type":"assistant/chunk","seq":123,"time":1781834686290,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":124,"time":1781834686318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":125,"time":1781834686319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":126,"time":1781834686319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":127,"time":1781834686352,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":128,"time":1781834686352,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":129,"time":1781834686381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":130,"time":1781834686469,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":131,"time":1781834686469,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":132,"time":1781834686497,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":133,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":134,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":135,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":136,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":137,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"cat"}}} -{"type":"assistant/chunk","seq":139,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":140,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":141,"time":1781834686559,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":142,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":143,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":144,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":145,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":146,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":147,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":148,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"Read"}}} -{"type":"assistant/chunk","seq":149,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":150,"time":1781834686655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":151,"time":1781834686655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":152,"time":1781834686684,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" confirm"}}} -{"type":"assistant/chunk","seq":153,"time":1781834686684,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":154,"time":1781834686713,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":155,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Appended successfully. Now read the file."}}}} -{"type":"assistant/chunk","seq":156,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} -{"type":"assistant/chunk","seq":157,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}}} -{"type":"assistant/chunk","seq":158,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":159,"time":1781834686745,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Appended successfully. Now read the file."},{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}]}} -{"type":"usage","seq":160,"time":1781834686745,"data":{"turn":1,"step":2,"usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}} -{"type":"tool/call","seq":161,"time":1781834686745,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} -{"type":"tool/result","seq":162,"time":1781834686758,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","content":[{"type":"text","text":"hello\nWORLD\n"}],"isError":false}} -{"type":"step/end","seq":163,"time":1781834686758,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":164,"time":1781834686758,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":165,"time":1781834687255,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":166,"time":1781834687255,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":167,"time":1781834687336,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":168,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} -{"type":"assistant/chunk","seq":169,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} -{"type":"assistant/chunk","seq":170,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":171,"time":1781834687366,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} -{"type":"assistant/chunk","seq":172,"time":1781834687396,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":173,"time":1781834687425,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":174,"time":1781834687426,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":175,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":176,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":177,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":178,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":179,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":180,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":181,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":182,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":183,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."}}}} -{"type":"assistant/chunk","seq":184,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":185,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":186,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":187,"time":1781834687489,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."},{"type":"text","text":"DONE"}]}} -{"type":"usage","seq":188,"time":1781834687489,"data":{"turn":1,"step":3,"usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}} -{"type":"step/end","seq":189,"time":1781834687489,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":190,"time":1781834687489,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/message","seq":114,"time":1781834685387,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."},{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}],"usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}} +{"type":"tool/call","seq":115,"time":1781834685387,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}} +{"type":"tool/result","seq":116,"time":1781834685400,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","content":[{"type":"text","text":"(no output)"}],"isError":false}} +{"type":"step/end","seq":117,"time":1781834685400,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":118,"time":1781834685400,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":119,"time":1781834686163,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":120,"time":1781834686163,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"App"}}} +{"type":"assistant/chunk","seq":121,"time":1781834686261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ended"}}} +{"type":"assistant/chunk","seq":122,"time":1781834686290,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":123,"time":1781834686318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":124,"time":1781834686319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":125,"time":1781834686319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":126,"time":1781834686352,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":127,"time":1781834686352,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":128,"time":1781834686381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":1781834686469,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":130,"time":1781834686469,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":131,"time":1781834686497,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":132,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":133,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":134,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":135,"time":1781834686498,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":136,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":137,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"cat"}}} +{"type":"assistant/chunk","seq":138,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} +{"type":"assistant/chunk","seq":139,"time":1781834686530,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":140,"time":1781834686559,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":141,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":142,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":143,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":144,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":145,"time":1781834686591,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":146,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"Read"}}} +{"type":"assistant/chunk","seq":148,"time":1781834686623,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} +{"type":"assistant/chunk","seq":149,"time":1781834686655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":150,"time":1781834686655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":151,"time":1781834686684,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" confirm"}}} +{"type":"assistant/chunk","seq":152,"time":1781834686684,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":153,"time":1781834686713,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":154,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Appended successfully. Now read the file."}}}} +{"type":"assistant/chunk","seq":155,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} +{"type":"assistant/chunk","seq":156,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}}} +{"type":"assistant/chunk","seq":157,"time":1781834686745,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":158,"time":1781834686745,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Appended successfully. Now read the file."},{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}],"usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}} +{"type":"tool/call","seq":159,"time":1781834686745,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} +{"type":"tool/result","seq":160,"time":1781834686758,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","content":[{"type":"text","text":"hello\nWORLD\n"}],"isError":false}} +{"type":"step/end","seq":161,"time":1781834686758,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":162,"time":1781834686758,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":163,"time":1781834687255,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":164,"time":1781834687255,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":165,"time":1781834687336,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":166,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":167,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":168,"time":1781834687365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":169,"time":1781834687366,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":170,"time":1781834687396,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":171,"time":1781834687425,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":172,"time":1781834687426,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":173,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":174,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":175,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":176,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":177,"time":1781834687455,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":178,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":179,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":180,"time":1781834687484,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":181,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."}}}} +{"type":"assistant/chunk","seq":182,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":183,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":184,"time":1781834687488,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":185,"time":1781834687489,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}} +{"type":"step/end","seq":186,"time":1781834687489,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":187,"time":1781834687489,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b5d7aca146..f0eb6fb88a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -154,7 +154,7 @@ export interface LoopHandle { * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) * session('assistant/chunk'); emit agent/stream-chunk * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the - * session('assistant/message','usage') session records what actually ran + * session('assistant/message' {content, usage?}) session records what actually ran * each tool-call in msg (sequential, abort-checked): * session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute * session('tool/result') @@ -313,41 +313,24 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, return false } - // Record a step/turn failure exactly once: append the single `error` event - // (only while the turn is still open — see below), set the error reason, and - // emit agent/error (contained — trap: a throwing agent/error listener must not - // re-escape and strand the turn). Disposal and abort set `reason` directly - // without calling this (no `error` event for those — they are not failures). + // Record a step/turn failure exactly once: set the error reason (carrying the + // failing `step` — the durable failure lives entirely on turn/end.reason, there + // is no separate session error event) and emit agent/error (contained — trap: a + // throwing agent/error listener must not re-escape and strand the turn). + // Disposal and abort set `reason` directly without calling this (they are not + // failures). const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // Only append the session `error` INSIDE the turn (before turn/end). If the - // turn has already ended — the only way here is a throwing agent/turn-end - // listener after closeTurn(true) already appended turn/end — appending now - // would land the error AFTER the last turn/end, where the persistence - // backend treats it as a crash tail and drops it on resume (the turn-enclosure RFC). In - // that case report via agent/error + the logger only; the turn is balanced. - if (!turnEnded) { - // Set `reason` BEFORE the append: Session.append pushes the error event - // before notifying session/event listeners, so a throwing listener would - // otherwise leave `reason` unset (and closeTurn would record the wrong - // reason / the outer catch would skip closeTurn). The append is contained - // — the error event is already in the log either way; a throwing listener - // must not abort finalization. - reason = { kind: 'error', ...errorData(err) } - try { - session.append('error', { turn, step, ...errorData(err) }) - } catch (appendError: unknown) { - ctx.logger.warn(`agent "${agent.id}": session/event listener threw on the error event at turn ${turn}: ${toError(appendError).message}`) - } - } else { - ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`) - } + // Set `reason` here so the durable failure is captured before closeTurn + // appends turn/end. The step number rides along so the operational error's + // location survives in the durable log. + reason = { kind: 'error', step, ...errorData(err) } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // contained: the error is already logged; a throwing agent/error - // listener must not prevent the turn from closing. + // contained: the error is already captured on `reason`; a throwing + // agent/error listener must not prevent the turn from closing. } } @@ -608,11 +591,14 @@ async function runStep( if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))) - if (message.content.length > 0) { - session.append('assistant/message', { turn, step, content: message.content }) - } - if (assembler.usage) { - session.append('usage', { turn, step, usage: assembler.usage }) + // Fire the assistant/message when there is content OR usage: a max-tokens + // step can be cut off with empty content but still carry token accounting, + // and assistant/message is the only host for usage (there is no standalone + // usage event). An empty-content assistant/message is skipped by + // deriveMessages(), so hosting usage on it never injects a spurious assistant + // turn into derived history. + if (message.content.length > 0 || assembler.usage) { + session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) } return { hadToolCalls: false, finish: assembler.finish } } @@ -623,10 +609,7 @@ async function runStep( let message: Message = assembler.message() message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)) - session.append('assistant/message', { turn, step, content: message.content }) - if (assembler.usage) { - session.append('usage', { turn, step, usage: assembler.usage }) - } + session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) // --- Tool execution (sequential; parallel execution is a TODO) --- // ToolRegistry.execute converts tool failures (including aborts) into diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 12036d7aec..3eefbf6986 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -213,9 +213,9 @@ describe('toError normalization', () => { expect(errors).toHaveLength(1) expect(errors[0]!.message).toBe('naked string error') // A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the - // session error event carries a routable code instead of degrading. - const errorEvent = agent.session.events.find(e => e.type === 'error') - expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN') + // turn-end error reason carries a routable code instead of degrading. + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN') }) it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { @@ -240,8 +240,8 @@ describe('toError normalization', () => { expect(errors).toHaveLength(1) // String() of { code: 500 } is '[object Object]' expect(errors[0]!.message).toBe('[object Object]') - const errorEvent = agent.session.events.find(e => e.type === 'error') - expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN') + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN') }) }) @@ -268,11 +268,11 @@ describe('coded error data emission', () => { expect(errors).toHaveLength(1) expect(errors[0]!.message).toBe('server overloaded') - // session error event includes the code - const errorEvent = agent.session.events.find(e => e.type === 'error') - expect(errorEvent).toBeDefined() - if (errorEvent!.type === 'error') { - expect(errorEvent!.data.code).toBe('RATE_LIMIT') + // turn-end error reason includes the code + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd).toBeDefined() + if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { + expect(turnEnd.data.reason.code).toBe('RATE_LIMIT') } }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 18dbafeb04..8cb60e99b7 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -58,11 +58,13 @@ describe('agent loop', () => { const types = agent.session.events.map(e => e.type) // turn/start opens the turn, THEN the queued user message is recorded inside - // it (every event is turn-enclosed), then assembled message + usage. + // it (every event is turn-enclosed), then the assembled message (carrying the + // step's usage). expect(types[0]).toBe('turn/start') expect(types[1]).toBe('user/message') expect(types).toContain('assistant/message') - expect(types).toContain('usage') + const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length }) expect(types.at(-1)).toBe('turn/end') // derived history: user + assistant @@ -442,6 +444,47 @@ describe('agent loop', () => { expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) expect(reasons).toEqual([{ kind: 'max-tokens' }]) + // No-data-loss: a max-tokens step whose only content was a dropped tool call + // has EMPTY assistant content, but its usage must still be represented. It + // rides on an (empty-content) assistant/message — there is no standalone + // usage event — and that empty message is skipped by deriveMessages(), so + // the derived history above is NOT corrupted by a spurious assistant turn. + const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({ + turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 }, + }) + }) + + it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => { + // A max-tokens step truncated to a dropped tool call AND with no usage chunk + // has nothing to record: empty content and no accounting → no assistant/message + // (the empty-content host exists only to carry usage). The turn still ends + // max-tokens. + const callId = CallId('c1') + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ]]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', + description: '', + parameters: { text: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'should not run' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(reasons).toEqual([{ kind: 'max-tokens' }]) + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) }) it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => { @@ -563,7 +606,10 @@ describe('agent loop', () => { expect(errors).toHaveLength(1) expect(errors[0]!.message).toContain('script exhausted') expect(reasons[0]).toMatchObject({ kind: 'error' }) - expect(agent.session.events.some(e => e.type === 'error')).toBe(true) + // The durable failure lives entirely on turn/end.reason (with the failing + // step), not a standalone error event. + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 }) }) it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => { diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 0d2441c7db..3695904322 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -492,11 +492,13 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', message: 'provider 401', code: 'AUTH' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }]) const events = [...agent.session.events] - expect(events.some(event => event.type === 'error' - && event.data.message === 'provider 401' && event.data.code === 'AUTH')).toBe(true) + // The durable failure lives on turn/end.reason (with the failing step), not + // a standalone error event. + const turnEnd = events.find(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }) // Crucially: no assistant/message was logged for the failed step. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) @@ -515,7 +517,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', message: 'model stream aborted', code: 'ABORTED' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }]) expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) }) @@ -533,7 +535,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', message: 'codeless failure' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }]) }) }) @@ -592,7 +594,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar turnEnd: e.filter(x => x.type === 'turn/end').length, stepStart: e.filter(x => x.type === 'step/start').length, stepEnd: e.filter(x => x.type === 'step/end').length, - errors: e.filter(x => x.type === 'error').length, + errors: e.filter(x => x.type === 'turn/end' && x.data.reason.kind === 'error').length, lastTurnEnd: e.findLast(x => x.type === 'turn/end'), } } @@ -611,10 +613,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar await waitForIdle(ctx, agent) const c = boundaryCounts(agent) - // turn opened and closed; no step ran; exactly one error logged + emitted. + // turn opened and closed; no step ran; exactly one error turn-end + emitted. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 0, stepEnd: 0, errors: 1 }) expect(errors.map(e => e.message)).toEqual(['boom turn-start']) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', message: 'boom turn-start' }) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', step: 0, message: 'boom turn-start' }) // model was never called (we threw before the step's request). expect(adapter.requests).toHaveLength(0) }) @@ -664,7 +666,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c.turnStart).toBe(1) expect(c.turnEnd).toBe(1) expect(c.stepStart).toBe(c.stepEnd) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', message: 'provider 500' }) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' }) // loop survives: a second turn runs to completion (invariants oracle would // throw on its turn/start if turn 1 had been left open). @@ -701,8 +703,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(turnStarts).toBe(1) expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal expect(reasons).toEqual([{ kind: 'disposed' }]) - // no error event: disposal is not a failure. - expect(e.some(x => x.type === 'error')).toBe(false) + // no error reason: disposal is not a failure. + expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) }) it('preserves reason disposed when the turn-end emit throws during disposal (outer-catch disposed branch)', async () => { @@ -741,9 +743,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - // The throwing turn-end listener is contained: no error event is logged and - // no agent/error is emitted (disposal is not a failure; the throw is swallowed). - expect(e.some(x => x.type === 'error')).toBe(false) + // The throwing turn-end listener is contained: the turn/end carries the + // disposed reason (not an error) and no agent/error is emitted (disposal is + // not a failure; the throw is swallowed). + expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -840,11 +843,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar await waitForIdle(ctx, agent) const c = boundaryCounts(agent) - // step opened and closed; exactly one error; turn balanced; turn ends error. + // step opened and closed; exactly one error turn-end; turn balanced. expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) expect(errors.map(e => e.message)).toEqual(['boom step-end']) expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason) - .toEqual({ kind: 'error', message: 'boom step-end' }) + .toEqual({ kind: 'error', step: 1, message: 'boom step-end' }) // step/end precedes turn/end (ordering contract) const e = [...agent.session.events] @@ -882,12 +885,12 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar await waitForIdle(ctx, agent) const c = boundaryCounts(agent) - // exactly one error event + one agent/error emit, despite two failTurn calls. + // exactly one error turn-end + one agent/error emit, despite two failTurn calls. expect(c.errors).toBe(1) expect(errors.map(e => e.message)).toEqual(['provider down']) expect(c.turnStart).toBe(1) expect(c.turnEnd).toBe(1) // single turn/end, balanced - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', message: 'provider down' }) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider down' }) // loop survives the compound failure. send(agent, 'again') @@ -895,42 +898,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(boundaryCounts(agent).turnEnd).toBe(2) }) - it('a throwing session/event listener on the error event still closes the turn (finalizer containment)', async () => { - // failTurn appends the `error` event; Session.append pushes it BEFORE - // notifying session/event listeners, so a throwing listener leaves `error` - // in the log but must NOT abort finalization — `reason` is set before the - // append and the throw is contained, so closeTurn(false) still runs and - // turn/end is appended (the turn is balanced, not left open). - // Plain harness (no invariants oracle): the throwing listener is itself a - // session/event subscriber. A finish-error drives the boundary-error path. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] - const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-errthrow'), { model: 'mock' }) - - let threw = false - ctx.on('session/event', (_s, event) => { - if (!threw && event.type === 'error') { threw = true; throw new Error('boom error-event listener') } - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const e = [...agent.session.events] - // The error event is in the log (pushed before the listener threw)… - expect(e.some(x => x.type === 'error')).toBe(true) - // …and the turn was still closed with the error reason (finalization did not - // abort): the last event is turn/end carrying the error reason. - const last = e.at(-1) - expect(last?.type).toBe('turn/end') - expect(last?.type === 'turn/end' && last.data.reason).toMatchObject({ kind: 'error', message: 'provider down' }) - - // loop survives: a second turn runs normally. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) - }) - it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => { // A throwing agent/step-start listener drives the outer catch, which calls // closeStep() during finalization. closeStep appends step/end; a diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 8c2f62cc1b..74c543fc07 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -45,7 +45,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. ### Session event vocabulary (`types.ts`) -The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `usage`, `error`. +The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index f86916d37f..55eeb523a5 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -160,7 +160,10 @@ export class Session { * * - `user/message` → user message * - `assistant/message` → assistant message (chunks are skipped — they are - * replay/UI data; the assembled message is authoritative for history) + * replay/UI data; the assembled message is authoritative for history). An + * EMPTY-content assistant/message is skipped: a max-tokens step cut off with + * no content still records an assistant/message to host its `usage`, but a + * content-less assistant turn must not enter the provider transcript. * - `tool/result` → user message carrying a tool-result block * - `context/message` / `steering/message` → tagged synthetic user messages * at their chronological position @@ -186,6 +189,10 @@ export class Session { break } case 'assistant/message': { + // Skip an empty-content assistant/message: it exists only to host a + // max-tokens step's usage and must not inject a content-less assistant + // turn into the provider transcript. + if (event.data.content.length === 0) break messages.push({ role: 'assistant', content: structuredClone(event.data.content) }) break } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 4b7334b207..ab9159377d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -88,7 +88,13 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] export interface TurnEndReasonMap { completed: { kind: 'completed' } aborted: { kind: 'aborted'; reason?: string } - error: { kind: 'error'; message: string; code?: string } + /** + * The turn failed: a step threw or the model reported a failure. `step` is the + * step number the failure occurred on (the operational error's location — the + * single durable record of an in-turn failure; live diagnostics also fire via + * `agent/error`). `code` is the error's code when one was attached. + */ + error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** @@ -140,14 +146,17 @@ export interface SessionEventMap { 'context/message': { content: ContentBlock[]; source: MessageSource } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } - /** Assembled assistant message for one step (derived history uses this). */ - 'assistant/message': { turn: number; step: number; content: ContentBlock[] } + /** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - 'usage': { turn: number; step: number; usage: TokenUsage } - 'error': { turn: number; step: number; message: string; code?: string } } export type SessionEventType = keyof SessionEventMap diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 4d0b1b1e71..42149515f2 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -24,6 +24,7 @@ const textContentArb = fc.array( const messageEventArb: fc.Arbitrary = fc.oneof( textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } } })), textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content } })), + textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, usage: { inputTokens: 1, outputTokens: 1 } } })), fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() }) .map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError } })), ) @@ -35,8 +36,6 @@ const nonMessageEventArb: fc.Arbitrary = fc.oneof( fc.constant({ type: 'step/start', data: { turn: 1, step: 1 } }), fc.constant({ type: 'step/end', data: { turn: 1, step: 1 } }), fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })), - fc.constant({ type: 'usage', data: { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } } }), - fc.constant({ type: 'error', data: { turn: 1, step: 1, message: 'x' } }), ) const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 9b4b4ddfa0..804093380f 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -192,8 +192,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { // turn as its commit/replay boundary (the JSONL backend treats anything // after the last turn/end as a crash tail), so a bare event between turns is // silently dropped on reload. The loop records queued user messages after - // turn/start, an idle agent.inject() wraps its context/message in a one-shot - // turn, and usage/error are only appended inside an open turn. A `default` + // turn/start, and an idle agent.inject() wraps its context/message in a + // one-shot turn. A `default` // (not an enumerated list) is deliberate: SessionEventMap is // merge-extensible, so a PLUGIN-added event type appended while idle must // also fail here rather than fall through and be dropped on resume. diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index f1bf2a2e74..b9add0479f 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -95,14 +95,12 @@ describe('session-log invariants', () => { .toThrow(/outside any open turn/) }) - it('rejects usage/error and plugin-added events appended outside any open turn', async () => { + it('rejects steering and plugin-added events appended outside any open turn', async () => { const { ctx } = await setup({ freeze: false }) const session = ctx.sessions.create() - // usage and error are turn-scoped: outside a turn they would land past the + // steering/message is turn-scoped: outside a turn it would land past the // commit boundary and be dropped on resume (the turn-enclosure RFC). - expect(() => session.append('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } })) - .toThrow(/outside any open turn/) - expect(() => session.append('error', { turn: 1, step: 1, message: 'boom' })) + expect(() => session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) .toThrow(/outside any open turn/) // A PLUGIN-added (merge-extensible) event type is caught by the default too. expect(() => session.append('compaction/marker' as never, { foo: 'bar' } as never)) @@ -156,7 +154,7 @@ describe('session-log invariants', () => { session.append('step/start', { turn: 1, step: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' }) session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', message: 'boom' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) }).not.toThrow() }) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index f16e988035..556fb5abff 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -130,11 +130,11 @@ describe('deriveReplayScript', () => { }) it('throws on a group that lacks a terminal finish chunk (a thrown stream)', () => { - // A thrown stream(): prefix chunks logged, then error/turn/end, NO finish. + // A thrown stream(): prefix chunks logged, then turn/end (error reason), NO finish. const events: SessionEvent[] = [ chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }), - { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', message: 'x' } } }, + { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'x' } } }, ] expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s) }) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index d7662cfebf..ec79e97443 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -775,7 +775,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * generic fallback (title = tool name, raw args as input) when no registry is * available (e.g. pure translator tests). * - * Other event types (turn/step boundaries, context/message, usage, …) produce + * Other event types (turn/step boundaries, context/message, …) produce * no client update. */ export function streamSessionEventUpdate( @@ -873,7 +873,7 @@ export function streamSessionEventUpdate( }) return } - // turn/step boundaries, context/message, steering, usage, error, + // turn/step boundaries, context/message, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 38a7a6cb41..9d82fe7533 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -16,7 +16,7 @@ describe('turnEndToStopReason', () => { expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens') expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled') - expect(turnEndToStopReason({ kind: 'error', message: 'boom' })).toBe('end_turn') + expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn') }) it('falls back to end_turn for an unknown (merge-extensible) future kind', () => { diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 737ad3804a..30cbd17c40 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -116,7 +116,7 @@ describe('streamSessionEventUpdate', () => { it('produces no update for boundary/other event types', () => { expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([]) - expect(updatesFor(evt('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } }))).toEqual([]) + expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([]) }) }) From c44ae5570c20324671de952659759b6e791a0f59 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:03:30 +0800 Subject: [PATCH 69/87] fix review findings: whenIdle() is observation, not the teardown await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's confirmation pass found the teardown-framing error went deeper than the three prose spots already fixed: the whenIdle() JSDoc itself (and its mirrors) claimed "the quiescence signal a teardown awaits ... a lifecycle owner disposes the agent through its AgentHandle which ... awaits THIS". The disposer does not call whenIdle() — it does `stop(); await agent.done` directly (packages/core/agent-loop/src/index.ts:271). whenIdle() is the NON-OWNER observation hook; owner teardown awaits the loop-exit promise (done) through AgentHandle.dispose(). Reframe every copy accordingly: - packages/core/agent/src/types.ts: the Agent.whenIdle() contract JSDoc. - packages/core/agent-loop/src/agent.ts: the impl JSDoc. - packages/core/agent/README.md and docs/core-data-structures/core.md (the type-equiv mirror of the types.ts JSDoc — re-copied verbatim). - docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md:40 and :70: owner teardown via AgentHandle.dispose(); a non-owner observing quiescence uses the interface-level agent.whenIdle(), not hand-rolled agent/status. - Regenerate the cordis catalog (whenIdle source line moved). --- docs/cordis-catalog/events-and-services.md | 28 +++++++++---------- docs/core-data-structures/core.md | 18 ++++++------ .../2026-06-14-acp-agent-client-protocol.md | 4 +-- packages/core/agent-loop/src/agent.ts | 6 ++-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 18 ++++++------ 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index afa4356880..cfe4a5138e 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:137`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:135`](../../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:143`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:141`](../../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:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:161`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ae6086b7b7..512b0fb838 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -253,12 +253,14 @@ interface Agent { /** * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: a lifecycle owner disposes the agent - * through its `AgentHandle` (which aborts in-flight work then awaits this), so - * the caller proceeds only after queued/running work has fully stopped (a - * closing ACP connection, a disposing UI plugin) rather than returning while - * the driver is still streaming or about to start a queued turn. + * `running`, or immediately if it is already idle with no queued work. A + * non-owner's quiescence-observation hook: a consumer that does NOT own the + * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to + * proceed only after queued/running work has fully stopped, rather than + * returning while the driver is still streaming or about to start a queued + * turn. It does NOT tear the agent down — a lifecycle owner stops and + * unregisters the agent through its `AgentHandle.dispose()` (which awaits the + * loop-exit promise directly), separate from this. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop @@ -266,10 +268,6 @@ interface Agent { * to actually exit (the implementation chains the loop-exit promise), not just * observe the status flip. A mid-step disposal that never reaches `idle` still * unblocks the await this way. - * - * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing - * the agent down. A consumer that owns the agent's lifecycle disposes it - * separately. */ whenIdle(): Promise diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 8244cab354..675e295c2c 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Disposal must come through the `dsh-agent` handle seam, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so a bridge that wanted to wait on quiescence directly would instead observe `agent/status` reaching `idle`/`disposed` — but routing teardown through the handle's `dispose()` makes that unnecessary. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Owner teardown goes through that handle seam, not the loop's concrete `agent.done` (which exists only on `ReactLoopAgent`); a non-owner that merely wants to *observe* the current work settling without tearing the agent down awaits the interface-level `agent.whenIdle()`. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. @@ -67,7 +67,7 @@ New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sd Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang. -Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `ReactLoopAgent`-only), not orphan awaits on a closed pipe. +Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence — tear each owned agent down through `AgentHandle.dispose()` (which stops the loop and awaits its exit), rather than orphaning awaits on a closed pipe. The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 12f15868dc..402a9a8416 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -228,8 +228,10 @@ export class ReactLoopAgent implements Agent { * internal waiter (see {@link idleWaiters}) released on the next * running→idle/disposed transition, resolving on `idle` directly (the turn * fully ended) or chaining {@link done} on `disposed` (wait for the loop to - * actually exit). Implements the {@link Agent.whenIdle} contract used by - * teardown (handle disposal aborts in-flight work, then awaits `whenIdle()`). + * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner + * quiescence-observation hook, distinct from teardown (a lifecycle owner stops + * and unregisters via `AgentHandle.dispose()`, which awaits {@link done} + * directly, not through this). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 28a8cd0cf0..d0ec0ee614 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -57,7 +57,7 @@ The handle every plugin programs against: - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent. +- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` ### Extension points diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index be0394adc5..ddb249b535 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -99,12 +99,14 @@ export interface Agent { /** * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: a lifecycle owner disposes the agent - * through its `AgentHandle` (which aborts in-flight work then awaits this), so - * the caller proceeds only after queued/running work has fully stopped (a - * closing ACP connection, a disposing UI plugin) rather than returning while - * the driver is still streaming or about to start a queued turn. + * `running`, or immediately if it is already idle with no queued work. A + * non-owner's quiescence-observation hook: a consumer that does NOT own the + * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to + * proceed only after queued/running work has fully stopped, rather than + * returning while the driver is still streaming or about to start a queued + * turn. It does NOT tear the agent down — a lifecycle owner stops and + * unregisters the agent through its `AgentHandle.dispose()` (which awaits the + * loop-exit promise directly), separate from this. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop @@ -112,10 +114,6 @@ export interface Agent { * to actually exit (the implementation chains the loop-exit promise), not just * observe the status flip. A mid-step disposal that never reaches `idle` still * unblocks the await this way. - * - * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing - * the agent down. A consumer that owns the agent's lifecycle disposes it - * separately. */ whenIdle(): Promise From 9e2833d15a67596421b7a7dfeae032f3b730692b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:21:32 +0800 Subject: [PATCH 70/87] fix review findings: drop the false "closing ACP connection" whenIdle() example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whenIdle() JSDoc cited "a closing ACP connection" as a non-owner that awaits whenIdle(). That is false against the code: ACP OWNS its agent handles and tears them down via rec.dispose()/handle.dispose() (quiesce() at packages/ui/acp/src/index.ts:666-686), never whenIdle(). The only whenIdle() consumers are tests (acp dispose/turns/edges specs, agent specs) — which is genuinely why the primitive stays (a test harness programs against the seam), but the contract doc must not claim a production ACP path uses it. Replace the parenthetical with truthful non-owning observers (a test awaiting a turn to settle, a monitor) and state explicitly that an OWNER does not need whenIdle() because AgentHandle.dispose() already awaits the loop-exit promise. - packages/core/agent/src/types.ts: the Agent.whenIdle() contract JSDoc. - docs/core-data-structures/core.md: the type-equiv mirror (re-copied verbatim). - Regenerate the cordis catalog (whenIdle source line shifted). --- docs/cordis-catalog/events-and-services.md | 28 +++++++++++----------- docs/core-data-structures/core.md | 13 +++++----- packages/core/agent/src/types.ts | 13 +++++----- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index cfe4a5138e..277a05fde2 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:135`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:136`](../../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:141`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:142`](../../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:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:161`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 512b0fb838..b9600c6f89 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -255,12 +255,13 @@ interface Agent { * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. A * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to - * proceed only after queued/running work has fully stopped, rather than - * returning while the driver is still streaming or about to start a queued - * turn. It does NOT tear the agent down — a lifecycle owner stops and - * unregisters the agent through its `AgentHandle.dispose()` (which awaits the - * loop-exit promise directly), separate from this. + * agent's lifecycle awaits this to proceed only after queued/running work has + * fully stopped, rather than returning while the driver is still streaming or + * about to start a queued turn — without itself tearing the agent down. (A + * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the + * loop-exit promise directly as part of stopping and unregistering. So this is + * for a non-owning observer — e.g. a test awaiting a turn to settle, or a + * monitor — that wants the settle signal but must not dispose the agent.) * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index ddb249b535..6d27bf7256 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -101,12 +101,13 @@ export interface Agent { * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. A * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to - * proceed only after queued/running work has fully stopped, rather than - * returning while the driver is still streaming or about to start a queued - * turn. It does NOT tear the agent down — a lifecycle owner stops and - * unregisters the agent through its `AgentHandle.dispose()` (which awaits the - * loop-exit promise directly), separate from this. + * agent's lifecycle awaits this to proceed only after queued/running work has + * fully stopped, rather than returning while the driver is still streaming or + * about to start a queued turn — without itself tearing the agent down. (A + * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the + * loop-exit promise directly as part of stopping and unregistering. So this is + * for a non-owning observer — e.g. a test awaiting a turn to settle, or a + * monitor — that wants the settle signal but must not dispose the agent.) * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop From 4209e4af3f1af35c2e4eec965d788fe251efb48d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:36:57 +0800 Subject: [PATCH 71/87] test(snapshot): use session.jsonl as the only session-log artifact (drop session.golden.jsonl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model-driving ACP snapshot scenarios shipped both session.jsonl (the replay fixture) and session.golden.jsonl (the expected re-persisted log). For recorded scenarios the normalized fixture and golden were byte-identical — pure duplication. Remove session.golden.jsonl entirely: every model scenario now has at most one committed session-log artifact, session.jsonl, which doubles as the replay source AND the expected produced log. The snapshot test compares the replay run's persisted log against the session.jsonl fixture, normalizing BOTH sides — but each against its OWN volatile values, not a shared context. A raw harvested fixture bakes in the recording run's session id / cwd / timestamps, distinct from the live replay run's; since normalizeSessionLog scrubs cwd by exact string match, the fixture must be normalized against its own header (new fixtureContext helper) or its stale recorded cwd would leak unscrubbed and the compare would fail. The session side uses a normalized-string toEqual, NOT toMatchFileSnapshot, so a run never overwrites the fixture. Authored override scenarios (error-finish, cancel) now hold their expected produced log in session.jsonl. Verified llm-replay ignores the fixture for model chunks when an override exists: loadReplayScript() returns the override array and never reads config.file, so committing the full expected log there does not affect replay behavior. The required-fixture guard is now per-kind: every scenario needs input.json + stdout.golden.jsonl; model scenarios need session.jsonl; authored ones additionally need replay.override.json. Updates the ACP-snapshot-tests RFC to the reduced fixture set and moves the proposing RFC proposed -> implemented. --- docs/rfc/README.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 14 +- ...0-remove-redundant-snapshot-log-goldens.md | 6 +- examples/acp-agent/tests/acp.snapshot.ts | 62 +++++- .../snapshots/cancel/session.golden.jsonl | 8 - .../tests/snapshots/cancel/session.jsonl | 9 +- .../error-finish/session.golden.jsonl | 6 - .../snapshots/error-finish/session.jsonl | 7 +- .../snapshots/multi-turn/session.golden.jsonl | 64 ------ .../snapshots/text-turn/session.golden.jsonl | 34 ---- .../tool-call-turn/session.golden.jsonl | 105 ---------- .../workspace-edit/session.golden.jsonl | 189 ------------------ 12 files changed, 81 insertions(+), 425 deletions(-) rename docs/rfc/{proposed => implemented}/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md (74%) delete mode 100644 examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl delete mode 100644 examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl diff --git a/docs/rfc/README.md b/docs/rfc/README.md index e37f162164..b9a5eef295 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -75,7 +75,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [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 | -| [Use `session.jsonl` as the only snapshot session-log artifact](proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | ## Implemented @@ -138,6 +137,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Property-based testing for protocol-shaped code](implemented/testing/2026-06-11-property-based-testing.md) | 2026-06-11 | | [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 | ## 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 a5875993a3..2f0fc38bf9 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 @@ -48,16 +48,16 @@ Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL p `examples/base.yml` always 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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading so a stray key cannot trigger a live call. -### Two goldens: normalize, then snapshot +### Two surfaces: normalize, then compare -A snapshot run asserts **two** normalized goldens, because the harness's external surfaces are distinct: +A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct: -1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). -2. The **re-derived session JSONL** — the log the replay run itself persists, compared against the recorded fixture. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. +1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). Compared against a committed `stdout.golden.jsonl`. +2. The **re-persisted session JSONL** — the log the replay run itself persists, compared against the scenario's `session.jsonl`. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. There is no separate session golden: `session.jsonl` is BOTH the replay source (recorded scenarios) and the expected produced log. Both sides pass through `normalizeSessionLog` before comparing — the fixture is raw-harvested (its own real session id / cwd / timestamps) and the replay output has fresh ones, so each is scrubbed against ITS OWN volatile values (the fixture's read from its header line) and the comparison is on normalized form. For an authored override scenario the same `session.jsonl` holds the expected produced log; `replay.override.json` drives the model, and `llm-replay` ignores the fixture for model chunks when an override exists, so committing the expected log there does not affect replay. The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `assistant/message.usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. -Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the snapshot: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The goldens are themselves **JSONL** — one compact, normalized record per line, in the same shape as the surfaces they mirror (NDJSON on the wire, JSONL on disk: `stdout.golden.jsonl`, `session.golden.jsonl`), so they stay `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the golden store and the `-u`/`--update` "accept the diff" workflow. +Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the compare: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The committed `stdout.golden.jsonl` is itself **JSONL** — one compact, normalized record per line, in the same shape as the wire (NDJSON on the wire, JSONL on disk), so it stays `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the stdout golden store and the `-u`/`--update` "accept the diff" workflow; the session log is checked with a plain normalized-string equality against `session.jsonl`, NOT `toMatchFileSnapshot` (which would overwrite the fixture). ### Isolation: normalization now, sandbox later @@ -70,10 +70,10 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack ### Two subcommands, replay in the default gate -`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl`, and `--update`s both goldens in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens). +`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` for all; `session.jsonl` for model scenarios; `replay.override.json` additionally for authored ones). ## Consequences -A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the two `*.golden.jsonl` files, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the goldens — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples. +A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log, which doubles as the expected re-persisted log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the `stdout.golden.jsonl`, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the fixture and the stdout golden — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples. This RFC relates to but does not supersede the [proposed determinism RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md similarity index 74% rename from docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md rename to docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 55325a7ef7..af7cd59d06 100644 --- a/docs/rfc/proposed/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,6 +1,6 @@ # RFC: Use `session.jsonl` as the only snapshot session-log artifact -Status: proposed +Status: implemented (proposed and accepted 2026-06-20) ## Problem @@ -29,3 +29,7 @@ Stdout goldens remain unchanged; they are the editor-facing projection and are n ## What we give up Reviewers lose one artifact name that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to `session.jsonl` preserves the loop/persistence regression check without duplicating files. + +## Implementation note + +The comparison normalizes BOTH sides, but each against its OWN volatile values, not a shared context. A raw harvested `session.jsonl` bakes in the recording run's session id, cwd, and timestamps; the replay run produces fresh ones. `normalizeSessionLog` scrubs cwd by exact string match, so normalizing the fixture against the *replay* run's cwd would leave the recorded cwd in the header unscrubbed and the compare would fail. The harness therefore derives the fixture's normalize context from its OWN header line (`{ type:'session', id, cwd }`) — `fixtureContext()` in `acp.snapshot.ts` — so both sides scrub to the same `{{sessionId}}`/`{{cwd}}` tokens. An authored fixture copied from the old golden already carries the normalized header (`id:'{{sessionId}}'`, `cwd:'{{cwd}}'`), which yields those tokens as the volatile values and scrubs idempotently. The session-log side uses a plain normalized-string `toEqual`, NOT `toMatchFileSnapshot`, so a run never overwrites the fixture. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9f99ce9913..039dbace8c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -9,12 +9,16 @@ import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './s /** * ACP snapshot tests (REPLAY by default, keyless). Each scenario under * `snapshots//` ships an `input.json` (the client stdin script) and a - * recorded `session.jsonl` fixture; replay boots the real acp-agent subprocess, - * drives it, and diffs the normalized stdout transcript (and, for model - * scenarios, the re-persisted session log) against committed goldens. + * `session.jsonl` fixture; replay boots the real acp-agent subprocess, drives + * it, and diffs the normalized stdout transcript against the committed + * `stdout.golden.jsonl`. For model scenarios it ALSO checks the re-persisted + * session log — against the `session.jsonl` fixture itself, not a separate + * golden: the fixture doubles as the replay source (recorded scenarios) and the + * expected produced log (both sides normalized before comparing). * * `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the - * fixtures against the real API and refreshes the goldens in one pass. + * `session.jsonl` fixtures against the real API and refreshes the stdout golden + * in one pass. */ const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -46,6 +50,28 @@ const SCENARIOS: Scenario[] = [ { name: 'cancel', hasModelTurn: true, recorded: false }, ] +/** + * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own + * header line (`{ type: 'session', id, cwd }`). A committed fixture carries the + * session id and cwd of the run that harvested it — different from the live + * replay run — so normalizing it against the live run's ctx would leave those + * recorded values unscrubbed. Reading them from the header scrubs the fixture's + * own id/cwd to the same `{{sessionId}}`/`{{cwd}}` tokens the replay output gets. + * An authored fixture whose header is already normalized (`id:'{{sessionId}}'`, + * `cwd:'{{cwd}}'`) yields those tokens as the volatile values, so scrubbing them + * is an idempotent no-op. A header with no `cwd` falls back to a sentinel that + * cannot occur in a log (NOT `''`, which `String.split` would match on every + * character boundary and corrupt the output). + */ +function fixtureContext(fixture: string): NormalizeContext { + const firstLine = fixture.split('\n').find(line => line.trim().length > 0) ?? '{}' + const header = JSON.parse(firstLine) as { id?: unknown; cwd?: unknown } + return { + sessionIds: typeof header.id === 'string' ? [header.id] : [], + cwd: typeof header.cwd === 'string' ? header.cwd : '\0no-cwd\0', + } +} + for (const scenario of SCENARIOS) { describe(`snapshot: ${scenario.name}`, () => { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the @@ -80,8 +106,17 @@ for (const scenario of SCENARIOS) { if (scenario.hasModelTurn) { expect(result.sessionLog, 'a model scenario must persist a session log').toBeDefined() - await expect(normalizeSessionLog(result.sessionLog as string, ctx)) - .toMatchFileSnapshot(join(dir, 'session.golden.jsonl')) + // Compare the replay run's persisted log against the `session.jsonl` + // fixture — there is no separate session golden. Both sides pass through + // normalizeSessionLog so the comparison is on normalized form: the + // fixture is raw-harvested (its own real session id / cwd / timestamps), + // the replay output has fresh ones, and each is scrubbed against ITS OWN + // volatile values. The fixture's are read from its header line (a + // committed file cannot share the live run's ctx), so the stale recorded + // cwd/id are scrubbed too, not left to leak past the run's `ctx`. + const fixture = await readFile(join(dir, 'session.jsonl'), 'utf8') + expect(normalizeSessionLog(result.sessionLog as string, ctx)) + .toEqual(normalizeSessionLog(fixture, fixtureContext(fixture))) } }) }) @@ -99,11 +134,22 @@ describe('snapshot fixtures', () => { }) it('every registered scenario has its required fixture files', async () => { - for (const { name } of SCENARIOS) { + // Required files are per-KIND. Every scenario has an input script and an + // stdout golden. Only model scenarios persist a session log, so only they + // require `session.jsonl` (the replay source AND expected-log artifact); + // a no-model scenario boots `llm-replay` with an empty script and needs no + // session fixture. Authored scenarios additionally ship the + // `replay.override.json` sidecar that drives their model behavior. + for (const { name, hasModelTurn, recorded } of SCENARIOS) { const dir = join(SNAPSHOTS_DIR, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) - expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) + if (hasModelTurn) { + expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) + } + if (hasModelTurn && !recorded) { + expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true) + } } }) }) diff --git a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl deleted file mode 100644 index ecb5155beb..0000000000 --- a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index ab44090be6..ecb5155beb 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1 +1,8 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} +{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl deleted file mode 100644 index 9f6ee27674..0000000000 --- a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/end","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index ab44090be6..9f6ee27674 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1 +1,6 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}}} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/end","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl deleted file mode 100644 index 92f0465e66..0000000000 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl +++ /dev/null @@ -1,64 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"usage":{"inputTokens":106,"outputTokens":20,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":31,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":32,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":33,"time":0,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} -{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} -{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":60,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"usage":{"inputTokens":122,"outputTokens":21,"cacheReadTokens":768,"reasoningTokens":18}}} -{"type":"step/end","seq":61,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":62,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl deleted file mode 100644 index 9b8447bf17..0000000000 --- a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl +++ /dev/null @@ -1,34 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONG"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."}}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" without using any tools."},{"type":"text","text":"PONG"}],"usage":{"inputTokens":878,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}} -{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl deleted file mode 100644 index e9e72c2494..0000000000 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl +++ /dev/null @@ -1,105 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" S"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":" S"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"NA"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"PS"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"H"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"OT"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":" S"}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"NA"}}} -{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"PS"}}} -{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"H"}}} -{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"OT"}}} -{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":63,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo SNAPSHOT_OK` and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}],"usage":{"inputTokens":122,"outputTokens":95,"cacheReadTokens":768,"reasoningTokens":23}}} -{"type":"tool/call","seq":64,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","name":"bash","arguments":"{\"command\": \"echo SNAPSHOT_OK\", \"description\": \"Run echo SNAPSHOT_OK\"}"}} -{"type":"tool/result","seq":65,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_7bmU1TAadx8ADiZJ3BqC9330","content":[{"type":"text","text":"SNAPSHOT_OK\n"}],"isError":false}} -{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":67,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"S"}}} -{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"NA"}}} -{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PS"}}} -{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OT"}}} -{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":94,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":95,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":96,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":97,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."}}}} -{"type":"assistant/chunk","seq":98,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":101,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"SNAPSHOT_OK\". Now I need to reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":235,"outputTokens":28,"cacheReadTokens":768,"reasoningTokens":25}}} -{"type":"step/end","seq":102,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":103,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl deleted file mode 100644 index 4415e42f8f..0000000000 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl +++ /dev/null @@ -1,189 +0,0 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}}} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Append"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" containing"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WOR"}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LD"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Read"}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"cat"}}} -{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} -{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" confirm"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} -{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"But"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" want"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"a"}}} -{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" per"}}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" action"}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" so"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" separate"}}} -{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} -{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" app"}}} -{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ending"}}} -{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" '"}}} -{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"WOR"}}} -{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"LD"}}} -{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"'"}}} -{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" >>"}}} -{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":94,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":95,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":96,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":97,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":98,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":101,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"App"}}} -{"type":"assistant/chunk","seq":102,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"end"}}} -{"type":"assistant/chunk","seq":103,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" WORLD"}}} -{"type":"assistant/chunk","seq":104,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" line"}}} -{"type":"assistant/chunk","seq":105,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":106,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":107,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":108,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":109,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":110,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."}}}} -{"type":"assistant/chunk","seq":111,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}}}} -{"type":"assistant/chunk","seq":112,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}}} -{"type":"assistant/chunk","seq":113,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":114,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Append a second line containing \"WORLD\" to greeting.txt\n2. Read the file back with `cat greeting.txt` to confirm\n3. Reply with \"DONE\"\n\nBut they want \"a single bash call per action\" - so I'll do two separate bash calls: one for appending, one for reading."},{"type":"tool-call","id":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}],"usage":{"inputTokens":161,"outputTokens":146,"cacheReadTokens":768,"reasoningTokens":74}}} -{"type":"tool/call","seq":115,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","name":"bash","arguments":"{\"command\": \"echo 'WORLD' >> greeting.txt\", \"description\": \"Append WORLD line to greeting.txt\"}"}} -{"type":"tool/result","seq":116,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_r3tvHl3fD0tmV0GKQt032338","content":[{"type":"text","text":"(no output)"}],"isError":false}} -{"type":"step/end","seq":117,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":118,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":119,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":120,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"App"}}} -{"type":"assistant/chunk","seq":121,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ended"}}} -{"type":"assistant/chunk","seq":122,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":123,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":124,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":125,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":126,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":127,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":128,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":129,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":130,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":131,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":132,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":133,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":134,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":135,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":136,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":137,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"cat"}}} -{"type":"assistant/chunk","seq":138,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":139,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":140,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":141,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":142,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":143,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":144,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":145,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":146,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":147,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"Read"}}} -{"type":"assistant/chunk","seq":148,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" greeting"}}} -{"type":"assistant/chunk","seq":149,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":150,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":151,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":" confirm"}}} -{"type":"assistant/chunk","seq":152,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":153,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":154,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Appended successfully. Now read the file."}}}} -{"type":"assistant/chunk","seq":155,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}}}} -{"type":"assistant/chunk","seq":156,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}}} -{"type":"assistant/chunk","seq":157,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":158,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Appended successfully. Now read the file."},{"type":"tool-call","id":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}],"usage":{"inputTokens":193,"outputTokens":74,"cacheReadTokens":896,"reasoningTokens":9}}} -{"type":"tool/call","seq":159,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","name":"bash","arguments":"{\"command\": \"cat greeting.txt\", \"description\": \"Read greeting.txt to confirm\"}"}} -{"type":"tool/result","seq":160,"time":0,"data":{"turn":1,"step":2,"callId":"call_00_SkCP8dgN8aCbLiZDcYa68316","content":[{"type":"text","text":"hello\nWORLD\n"}],"isError":false}} -{"type":"step/end","seq":161,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":162,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":163,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":164,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":165,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":166,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} -{"type":"assistant/chunk","seq":167,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} -{"type":"assistant/chunk","seq":168,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":169,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} -{"type":"assistant/chunk","seq":170,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":171,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":172,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":173,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":174,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":175,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":176,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":177,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":178,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":179,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":180,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":181,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."}}}} -{"type":"assistant/chunk","seq":182,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":183,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":184,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":185,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file now has two lines. I'll reply with DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":156,"outputTokens":17,"cacheReadTokens":1024,"reasoningTokens":14}}} -{"type":"step/end","seq":186,"time":0,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":187,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From ddfb6573ee8c9965cf3f4ea369539917a7b56bc9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:02:08 +0800 Subject: [PATCH 72/87] fix review findings: correct the property-suite invariant description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dsh-llm property-suite bullet claimed the suite checks an ordered-prefix contract (the blocks push() returns incrementally are a prefix of final blocks(), in order) and streaming-vs-one-shot agreement on usage/finish. Both died with flushReady()/flushRemaining()/generate()/streamBlocks(): the ordered-prefix guarantee was provided by that flush pair, and push() never guaranteed it (index 0 opened by a delta then index 1 closed by block-end has push() return block 1 while final blocks() orders [0, 1] — the returned block is not a prefix). Rewrite the bullet to enumerate only what properties.spec.ts actually asserts: blocks() count <= distinct indices, idempotent re-assembly with message().content mirroring blocks(), blocks() never throwing and yielding valid tags, and finish reflecting the last finish chunk (defaulting to stop). --- .../implemented/testing/2026-06-11-property-based-testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index d737379b27..c3f1d1a46b 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -14,7 +14,7 @@ Example-based tests pin the cases we thought of. The harness's core is protocol- Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` per protocol-shaped package, generators tuned for *realistic-but-adversarial* inputs (not uniform noise) and `numRuns` kept so the suite stays well under ~10s locally. Failures print a reproducible seed. (The original proposal also sketched a nightly CI job running 100× the iterations; that was not shipped — the property suite runs only in the normal `push`/`pull_request` CI, and a scheduled high-iteration job remains possible future work.) -- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: the blocks `push()` returns incrementally are a prefix of the final `blocks()`, in order; partial count ≤ distinct indices; re-assembly idempotent; streaming and one-shot consumers agree on usage and finish. +- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives. - **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log. - **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk. - **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine. From 83e97ed222035616a1fff6d307649b9d1a920118 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:04:28 +0800 Subject: [PATCH 73/87] fix review findings: document the util/ group + align branded-ids RFC with dsh-brand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding packages/util/brand/ created a new top-level packages/util/ group that the hierarchy/dependency docs never enumerated. Document it: - Add packages/util/README.md, the group README (low-level zero-dependency utilities shared across groups; lists dsh-brand). - packages/README.md: add the util/ group to the group table, dsh-brand to the package table, and dsh-brand to the dependency graph. Correct the now-false "no harness deps" claims — dsh-llm and dsh-bash both depend on dsh-brand (verified dsh-bash imports Branded from dsh-brand, not dsh-llm; dsh-session and dsh-agent depend on it too). - Root AGENTS.md Repository Layout: add the util/ group with brand/. Align the implemented branded-ids RFC with what shipped: Branded lives in @deepseek-ai/dsh-brand (packages/util/brand/), and dsh-bash depends only on that utility package instead of dsh-llm. Fix the BashTaskId import source, the illustrative snippet, and the opening policy reference (now dsh-brand). --- AGENTS.md | 3 +++ .../architecture/2026-06-20-branded-ids.md | 6 +++--- packages/README.md | 11 +++++++---- packages/util/README.md | 9 +++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 packages/util/README.md diff --git a/AGENTS.md b/AGENTS.md index dc4e65a962..ee17175a69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,9 @@ packages/ Harness packages, grouped by role at packages///. 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) + util/ low-level zero-dependency utilities shared across groups + brand/ type-only Branded nominal-typing primitive (no runtime + code, no harness deps; owns the brand for cross-boundary ids) examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent = mock model + echo tool + stdio UI + JSONL persistence, wired via cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index c203e76d42..0b4975089d 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -4,7 +4,7 @@ Status: implemented (proposed and accepted 2026-06-20) ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. @@ -16,7 +16,7 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) @@ -25,7 +25,7 @@ A type-only change. Brands are zero-cost casts; nothing about runtime behavior, Illustrative shape (the factory pattern is identical to the three existing brands): ```ts ignore-check -import type { Branded } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' /** A background bash task handle (generated `bash-N` by the local executor). */ export type BashTaskId = Branded<'BashTaskId'> diff --git a/packages/README.md b/packages/README.md index 139050683d..e02454bb48 100644 --- a/packages/README.md +++ b/packages/README.md @@ -14,17 +14,19 @@ Packages are grouped by modular role at `packages///`. The group dir | [`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 | +| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` 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). ## Dependency graph ``` -dsh-llm (no harness deps — pure vocabulary) -dsh-bash (no harness deps — abstract executor seam) -dsh-session ← dsh-llm +dsh-brand (no harness deps — type-only Branded primitive) +dsh-llm ← dsh-brand (vocabulary; brands CallId) +dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken) +dsh-session ← dsh-llm, dsh-brand dsh-system-prompt ← dsh-llm -dsh-agent ← dsh-llm, dsh-session +dsh-agent ← dsh-llm, dsh-session, dsh-brand 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) @@ -61,6 +63,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l | `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `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`) | +| `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). diff --git a/packages/util/README.md b/packages/util/README.md new file mode 100644 index 0000000000..ae73c8125f --- /dev/null +++ b/packages/util/README.md @@ -0,0 +1,9 @@ +# util/ — low-level shared utilities + +Zero-dependency primitives shared across the other groups. A package lands here when it owns a tiny, foundational type or helper that several capability families need but that belongs to none of them — keeping it out of any one group avoids a capability package depending on an unrelated one just to reach a shared primitive. These are **support** packages: small, stable, and free of harness dependencies. + +| Package | Role | +|---|---| +| `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | + +`dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. From 00d76465581d3259730cd17e6eb3d150ad7afb77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:05:56 +0800 Subject: [PATCH 74/87] fix review findings: make the prune-seam RFC + index match the persistence-only shipped scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer caught two pieces of both-seams drift left over after the bash get()/list() removal was reverted to a persistence-only change. - docs/rfc/README.md: rename the index row from "persistence and bash seams" to "Prune dead methods from the persistence seam" so it matches the RFC title and the actually-shipped scope (verify-rfc-classification only checks the path is indexed, so this prose slipped the gate). - The implemented RFC body still read like the original both-seams proposal (the "Two capability seams" framing, a `### BashExecutor.get()/.list()` problem section, a bash removal bullet in the Proposal, and current-source links that imply bash get/list were removed). Rewrite the body into the durable decision-record form: Problem/Proposal/criteria/risks now describe only the persistence has()/delete() removal that shipped, and the bash reasoning (why get()/list() earn their keep — a ~35-line test-harness migration cost makes the test consumer a real consumer) is folded into the top decision note as "considered and deliberately kept", not as a shipped change. Drop the stale bash source-line refs; keep the persistence consumer links pointing at current code (agent-loop load, ACP session/list). --- docs/rfc/README.md | 2 +- .../2026-06-20-prune-dead-seam-methods.md | 33 +++++++------------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index be22e0c84b..b19e4933ac 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -95,7 +95,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | -| [Prune dead methods from the persistence and bash seams](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | +| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 3cf99e47ef..1b1c56c02f 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,50 +2,41 @@ Status: implemented (proposed and accepted 2026-06-20) -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. +> **Decision (scope: persistence only).** The shipped change removes the two dead persistence methods `SessionPersistence.has()` and `.delete()`; the body below records that decision. The bash seam's `BashExecutor.get()`/`.list()` were **considered for the same treatment and deliberately kept**: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them would force `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfs the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep: a test harness IS a consumer that programs against the seam, so `get()`/`list()` stay. (`BashTaskId`-branding those surviving methods is taken up by the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md).) The persistence removal carries no such cost: `has()`/`delete()` had only contract-test callers and no test-ergonomics consumer to migrate. ## Problem -Two capability seams ([interface / implementation / consumer](../../implemented/architecture/2026-06-13-capability-seams.md)) carry abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. +A capability seam ([interface / implementation / consumer](../../implemented/architecture/2026-06-13-capability-seams.md)) carries abstract methods that no consumer calls. The seam exists to let implementations and consumers evolve independently — but a method no consumer programs against is not a seam, it is speculative surface every implementation must still implement and test. ### `SessionPersistence.has()` and `.delete()` -The abstract service declares four operations beyond create/append: `load`, `list`, `has`, `delete` ([packages/session-persistence/session-persistence/src/index.ts:142-151](../../../../packages/session-persistence/session-persistence/src/index.ts)). Production consumers of `ctx.sessionPersistence` use only two of them: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176-194](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` are the contract suites and per-backend specs. +The abstract service declared its operations beyond create/append: `load`, `list`, `has`, `delete`. Production consumers of `ctx.sessionPersistence` use only two: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` were the contract suites and per-backend specs. -`has()` is not just unused — it is the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale ([packages/session-persistence/session-persistence/src/coordinator.ts:298-310](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)). `delete()` drags the `deleteStored` backend hook ([coordinator.ts:99](../../../../packages/session-persistence/session-persistence/src/coordinator.ts), [coordinator.ts:313-319](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)) that every backend must implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercises both, but no shipping code asks "is this session persisted?" or removes one. - -### `BashExecutor.get()` and `.list()` - -The bash seam declares `get(id)` ("look up a background task by id") and `list()` ("all tracked background tasks") ([packages/bash/bash/src/index.ts:88-107](../../../../packages/bash/bash/src/index.ts)), both implemented by `LocalBashExecutor` ([packages/bash/bash-local/src/index.ts:179-191](../../../../packages/bash/bash-local/src/index.ts)). The sole production consumer — `dsh-tool-bash` — drives tasks via `ownerOf`, `onTaskDone`, `start`, `readOutput`, `kill`, `resolve`, `run`; it never calls `get`/`list` in shipping code, and there is no `bash_list` tool exposing a task roster to the model. So both are dead production seam surface. They are used by tests, more broadly than a single idiom: the bash seam/executor specs assert them directly ([packages/bash/bash/tests/service.spec.ts](../../../../packages/bash/bash/tests/service.spec.ts), [packages/bash/bash-local/tests/executor.spec.ts](../../../../packages/bash/bash-local/tests/executor.spec.ts) both call `get()`/`list()`), and several `dsh-tool-bash` tests reach through `ctx.bash.get(id)` to await a task's `done`, read its `status`, or inspect task fields ([packages/bash/tool-bash/tests/tools.spec.ts](../../../../packages/bash/tool-bash/tests/tools.spec.ts), [packages/bash/tool-bash/tests/integration.spec.ts](../../../../packages/bash/tool-bash/tests/integration.spec.ts)). These are test-harness conveniences, not shipping consumers — but they are real test code an implementing PR must migrate or delete. +`has()` was not just unused — it was the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one. ## Proposal Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them: -- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implement `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. -- `BashExecutor.get()` / `.list()`: delete the abstract declarations and the `LocalBashExecutor` impls. The seam/executor specs that assert `get()`/`list()` directly (`bash/tests/service.spec.ts`, `bash-local/tests/executor.spec.ts`) lose those assertions (the behavior is being removed). The `dsh-tool-bash` tests that reach through `ctx.bash.get(id)` to await `done`, read `status`, or inspect task fields switch to the public completion/status seam they should use — `onTaskDone` (or the `done` promise and status the `start()` return already exposes) — keeping their coverage without the removed lookup method. -- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`get(`/`list(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`get`/`list`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam READMEs ([packages/session-persistence/session-persistence/README.md](../../../../packages/session-persistence/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four, [packages/bash/bash/README.md](../../../../packages/bash/bash/README.md)'s `get(id)`/`list()` row), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence/session-persistence-sqlite/README.md](../../../../packages/session-persistence/session-persistence-sqlite/README.md), [packages/session-persistence/session-persistence-jsonl/README.md](../../../../packages/session-persistence/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/session-persistence/src/index.ts](../../../../packages/session-persistence/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. +- `SessionPersistence.has()` / `.delete()`: delete the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook. Remove the `has`/`delete` rows from the contract suite and the per-backend specs (jsonl + sqlite each implemented `deleteStored` only to satisfy the hook — that implementation goes too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope, but removing a hook they implement for no consumer is part of removing the hook, not a backend redesign. +- Update every doc and source-comment reference to the removed methods — not only literal `has(`/`delete(`/`deleteStored` call spellings, but also `{@link has}`/`{@link delete}` JSDoc links and prose that counts the methods (removing 2 of the persistence service's 6 public methods makes any "six public methods" phrasing wrong). The implementing PR greps `has`/`delete`/`deleteStored`/`{@link `/`six ` across `docs/`, `packages/*/README.md`, and source comments, and fixes each. The known doc sites: the seam README ([packages/session-persistence/session-persistence/README.md](../../../../packages/session-persistence/session-persistence/README.md)'s `has(id)`/`delete(id)` API row and its "delegates its six public service methods" prose → four), the backend READMEs that describe `has`/`list` semantics ([packages/session-persistence/session-persistence-sqlite/README.md](../../../../packages/session-persistence/session-persistence-sqlite/README.md), [packages/session-persistence/session-persistence-jsonl/README.md](../../../../packages/session-persistence/session-persistence-jsonl/README.md) — reword "absent from `has()`/`list()`" to just `list()`), the service-map / seam docs in [docs/architecture.md](../../../architecture.md), and the persistence prose in the [session-persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) and [shared write-coordinator RFC](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The known source-comment sites: the abstract `create()` JSDoc's `{@link has}/{@link list}` link ([packages/session-persistence/session-persistence/src/index.ts](../../../../packages/session-persistence/session-persistence/src/index.ts) — drop the `has` link), the coordinator's "six public methods"/"six public service methods" module + class JSDoc and its lazy-materialization JSDoc justifying the `materialized` flag by "the signal `has`/`list` rely on" ([packages/session-persistence/session-persistence/src/coordinator.ts](../../../../packages/session-persistence/session-persistence/src/coordinator.ts)), the JSONL backend's `loadStored`/`deleteStored` comment, and the SQLite backend's `schema.ts` and `index.ts` comments that mention "absent from `has`/`list`" — all reworded to the surviving four-method, `list()`-only contract. ## Why not keep them as "the seam should be complete"? -The instinct that a persistence seam "should" offer delete, or a task executor "should" offer enumeration, is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). Each of these is one method to re-add the day a consumer needs it: - -- A session-management UI that deletes old sessions will want `delete()` — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now. -- A `bash_list` tool that shows the model its running tasks will want `list()` — add it with the tool. +The instinct that a persistence seam "should" offer delete is real — and it is exactly the speculative-completeness the pre-release stance warns against ([AGENTS.md](../../../../AGENTS.md): optimize for the correct foundation, not for hypothetical callers you do not have). `delete()` is one method to re-add the day a consumer needs it: a session-management UI that deletes old sessions will want it — add it then, designed against that UI's real needs (soft-delete? cascade? confirmation?), not guessed now. Re-adding a seam method with a live consumer is cheap and better-designed than the speculative version, because the consumer pins the contract. Carrying it unused means every implementation (and every future backend) must implement and test a method that does nothing. ## Acceptance criteria -- `has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites; `pnpm run knip` reports no new dead exports. (The bash `get`/`list` removal was reverted — see the implementation note above; those methods remain.) -- The remaining seam operations (`create`/`append`/`load`/`list` for persistence; `run`/`start`/`get`/`ownerOf`/`list`/`onTaskDone`/`readOutput`/`kill`/`resolve` for bash) are untouched; ACP `session/list`, bash tool flows, and crash-recovery behave identically. +- `has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites; `pnpm run knip` reports no new dead exports. +- The remaining persistence operations (`create`/`append`/`load`/`list`) are untouched; ACP `session/list` and crash-recovery behave identically. - `pnpm run test:coverage` stays 100% per-file (the contract/spec rows for the removed persistence methods are deleted with them). -- Persistence seam READMEs and `docs/architecture.md` no longer list the removed `has`/`delete` methods. +- The persistence seam README and `docs/architecture.md` no longer list the removed `has`/`delete` methods. ## Risks - **`delete()` is the kind of operation a product eventually wants.** True — but "eventually" is the point. Deleting it now and re-adding it against a real consumer is strictly better than shipping a guessed contract. The dual backends each shed a `deleteStored` impl, which is a bounded edit in otherwise-out-of-scope packages. -- **`list()` on the bash seam is the natural seed for a future `bash_list`.** Acknowledged in the [pre-release foundation stance](../../../../AGENTS.md): add the seed when the tool lands. The executor still tracks tasks internally (the `tasks` map backs `ownerOf`/`readOutput`/`kill`); exposing an enumeration is a one-line re-add. -- **Low coupling.** Both removals are confined to their seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs. +- **Low coupling.** The removal is confined to the persistence seam + impl + tests; no cross-package consumer references the removed methods, so there is no ripple beyond the docs. -Modest size, but it converts two seams from "what an implementation must provide for nobody" back to "exactly what a consumer uses." +Modest size, but it converts the seam from "what an implementation must provide for nobody" back to "exactly what a consumer uses." From b0422f2a50b68fcd9ac3b7fdc8b8fea852201d79 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:08:10 +0800 Subject: [PATCH 75/87] fix review findings: bump session format version + restore late turn-end warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test. --- AGENTS.md | 2 +- docs/core-data-structures/persistence.md | 6 +++- ...6-20-collapse-trace-only-session-events.md | 7 +++-- .../tests/snapshot-normalize.spec.ts | 2 +- .../snapshots/cancel/session.golden.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../error-finish/session.golden.jsonl | 2 +- .../snapshots/error-finish/session.jsonl | 2 +- .../tests/snapshots/handshake/session.jsonl | 2 +- .../snapshots/multi-turn/session.golden.jsonl | 2 +- .../tests/snapshots/multi-turn/session.jsonl | 2 +- .../snapshots/reject-extra-dirs/session.jsonl | 2 +- .../snapshots/text-turn/session.golden.jsonl | 2 +- .../tests/snapshots/text-turn/session.jsonl | 2 +- .../tool-call-turn/session.golden.jsonl | 2 +- .../snapshots/tool-call-turn/session.jsonl | 2 +- .../workspace-edit/session.golden.jsonl | 2 +- .../snapshots/workspace-edit/session.jsonl | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 6 ++-- packages/core/agent-loop/src/loop.ts | 28 ++++++++++++++----- packages/core/agent-loop/tests/loop.spec.ts | 19 +++++++++++++ .../agent-loop/tests/review-fixes.spec.ts | 6 +++- packages/core/session/src/index.ts | 6 ++-- packages/core/session/src/types.ts | 23 ++++++++++++++- packages/core/session/tests/session.spec.ts | 12 ++++---- .../session-persistence-jsonl/README.md | 2 +- .../tests/jsonl.spec.ts | 16 +++++------ .../session-persistence/src/coordinator.ts | 6 ++-- .../session-persistence/tests/contract.ts | 6 ++-- .../tests/coordinator-contract.ts | 6 ++-- .../llm-replay/tests/llm-replay.spec.ts | 4 +-- packages/ui/acp/tests/load.spec.ts | 6 ++-- 32 files changed, 127 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fdd25b552..419b7e41a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This is the monorepo for the DeepSeek Harness group. It currently hosts the code **This applies only while the harness is unreleased — remove this section at the first tagged/published release.** There are no external consumers yet, so optimize for the *correct foundation*, not for a small diff. When the right structure means moving a file across package boundaries, renaming a public symbol, or repackaging a plugin, do it — and update every reference in the same change. Do **not** add backward-compat shims, deprecation aliases, re-export stubs, or "keep it where it is to avoid churn" hedges; those are debts you take on to protect callers you do not have. Churn now is cheap; a wrong foundation set in stone is not. (Once released, this inverts — backward compatibility becomes a real constraint and this section comes out.) -This extends to **on-disk formats, schemas, and stored data**: while unreleased there is no persisted user data to preserve, so a format/schema/contract change needs **no migration path**. Bump the version and reject (don't migrate) anything not at the current version — e.g. the SQLite backend's `SCHEMA_VERSION` bump that drops columns simply rejects any non-current `user_version` on open, with no v1→v2 migration. A migration written now is a shim for data that does not exist. +This extends to **on-disk formats, schemas, and stored data**: while unreleased there is no persisted user data to preserve, so a format/schema/contract change needs **no migration path** — a backend REJECTS anything not at the current version rather than upgrading it. How the *version number itself* behaves pre-release is a per-format choice between two equally-valid stances, and the repo uses both deliberately. **Monotonic bump-and-reject**: each breaking change increments the version — e.g. the SQLite backend's `SCHEMA_VERSION` bump that drops columns rejects any non-current `user_version` on open, with no migration; use it when a stored artifact has a small enumerable set of revisions worth telling apart. **A pinned `0` "unstable / pre-release" version**: the format stays at `0` and absorbs ALL pre-release shape churn without bumping, while a backend still rejects any non-`0` log — the session event log uses this (`SESSION_FORMAT_VERSION = 0` in `dsh-session`), because its shape changes often while unreleased and bumping on every tweak would dress up an unstable format as a sequence of stable boundaries that mean nothing yet; pinning `0` and documenting it "no compatibility implied" makes the instability *explicit* instead of pretending each revision is a real version. Either way there is no migration code, and either way a real monotonic policy begins at the first tagged release. A migration written now is a shim for data that does not exist. ## Tests document behavior, not golden truth diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f1ee857998..45c1d8dd6b 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -20,7 +20,11 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t ```ts type-equiv interface SessionHeader { - /** On-disk format version; a persistence backend rejects unknown versions. */ + /** + * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the + * session is created. A persistence backend rejects any other version on load + * (no migration — see the constant). + */ version: number /** The session's id (mirrors the {@link Session}'s id). */ id: SessionId diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index 3a2c11bdc6..f0bc1f107f 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -26,7 +26,7 @@ If analytics become real, add a projection helper or a dedicated telemetry store - The loop records durable failures through `turn/end { kind: 'error', step, message, code? }` or an equivalent no-information-loss shape and reports live diagnostics through `agent/error`. - ACP snapshots and persistence tests stop asserting trace-only lines. - Documentation explains exactly where token usage and operational errors are observed. -- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy. +- Recorded fixtures are refreshed for the new event shape; the session format version stays pinned at `0` (unstable/pre-release) and backends reject any non-`0` stored log per the pre-release format policy. ## What we give up @@ -34,9 +34,10 @@ A consumer can no longer filter the canonical log for standalone `usage` or step ## Implementation note -Shipped as proposed, with two scope refinements (per AGENTS.md "RFCs are proposals, not golden truth"): +Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposals, not golden truth"): -- **No format-version bump.** The acceptance criterion "the session format version and recorded fixtures are refreshed" over-reached: the harness is pre-release with no persisted user data, so per the pre-release format policy there is nothing to migrate or reject. The session `version` stays `1`; only event shapes and recorded fixtures change. `turn/end.reason.error.step` is therefore optional-on-read for any hypothetical pre-existing log but guaranteed for newly-written ones — no migration shim. - **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. +**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`), so per the AGENTS.md "bump the version and reject — don't migrate" policy a backend must reject any non-current log. The version literal is centralized in an exported `SESSION_FORMAT_VERSION` constant (read by both write sites and the coordinator's load-time check). While the harness is unreleased the on-disk format is pre-release/unstable, so the constant stays **`0`**: a breaking format change is absorbed at v0 (no monotonic bump until the first tagged release, when a specific format boundary becomes worth distinguishing) and old logs at any other version are rejected on load — there is no v0→vN migration (no persisted user data exists). `turn/end.reason.error.step` is required for newly-written logs. + Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index bfdeab5dc8..b220344bb9 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -60,7 +60,7 @@ describe('normalizeStdout', () => { }) describe('normalizeSessionLog', () => { - const header = (over: object) => JSON.stringify({ type: 'session', version: 1, id: 's', createdAt: 123, ...over }) + const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over }) const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over }) it('zeroes the header createdAt', () => { diff --git a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl index ecb5155beb..fd2d0c3f23 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl index 9f6ee27674..42dd973de2 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/handshake/session.jsonl b/examples/acp-agent/tests/snapshots/handshake/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/handshake/session.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl index 92f0465e66..64102deef7 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 5d56942463..914eecdcb6 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"803f0752-a3db-4394-9c93-3b6fcd410664","createdAt":1781834688308,"cwd":"/tmp/acp-snap-cwd-QVaaKH"} +{"type":"session","version":0,"id":"803f0752-a3db-4394-9c93-3b6fcd410664","createdAt":1781834688308,"cwd":"/tmp/acp-snap-cwd-QVaaKH"} {"type":"turn/start","seq":0,"time":1781834688311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834688312,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834688312,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl index ab44090be6..a6f73319bc 100644 --- a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl +++ b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl @@ -1 +1 @@ -{"type":"session","version":1,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl index 9b8447bf17..8aafeb2b82 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index ed34f9a727..7509bf8ef9 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"b8c052fd-b33f-475a-8a0c-bc3b75527602","createdAt":1781834679270,"cwd":"/tmp/acp-snap-cwd-TJst85"} +{"type":"session","version":0,"id":"b8c052fd-b33f-475a-8a0c-bc3b75527602","createdAt":1781834679270,"cwd":"/tmp/acp-snap-cwd-TJst85"} {"type":"turn/start","seq":0,"time":1781834679273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834679273,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834679273,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl index e9e72c2494..34ccf11d37 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 3566adab87..3aae0d7ccb 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"0e5b2fc1-d220-4a81-b48b-939c14dea057","createdAt":1781834681068,"cwd":"/tmp/acp-snap-cwd-5F2H38"} +{"type":"session","version":0,"id":"0e5b2fc1-d220-4a81-b48b-939c14dea057","createdAt":1781834681068,"cwd":"/tmp/acp-snap-cwd-5F2H38"} {"type":"turn/start","seq":0,"time":1781834681072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834681073,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834681073,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl index 4415e42f8f..a4d92d9319 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index b7a54cfaeb..235267ad36 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":1,"id":"06bd4899-ec95-43e3-82ca-42c719d8b19b","createdAt":1781834683850,"cwd":"/tmp/acp-snap-cwd-Jsq2M2"} +{"type":"session","version":0,"id":"06bd4899-ec95-43e3-82ca-42c719d8b19b","createdAt":1781834683850,"cwd":"/tmp/acp-snap-cwd-Jsq2M2"} {"type":"turn/start","seq":0,"time":1781834683853,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1781834683854,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}}} {"type":"step/start","seq":2,"time":1781834683854,"data":{"turn":1,"step":1}} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 33c392ff7e..68cf71eda6 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -43,7 +43,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un // `session.header.id`, NOT the registry key. Using distinct values here makes // the test fail if a regression matched on the wrong field (a same-value fake // would pass either way — the "hits the line but not the scenario" trap). - const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 1, id: sessionId, createdAt: 0 } } } as unknown as Agent + const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent const dispose = ctx.agents.register(agent) const list = fakeAgentDisposers.get(ctx) ?? [] list.push(dispose) @@ -466,7 +466,7 @@ describe('background task ownership (cross-session isolation)', () => { // the same token). The impl reads `session.header.id`, so the fakes MUST carry // it. const fakeAgent = (sessionId: string) => - ({ inject: () => undefined, session: { header: { version: 1, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => { const ctx = await setup() @@ -582,7 +582,7 @@ describe('session-cwd routing (per-session workdir)', () => { } // An agent whose session header carries a cwd (what session/new records). const agentInCwd = (cwd: string) => - ({ inject: () => undefined, session: { header: { version: 1, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => { const ctx = await setup() diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f0eb6fb88a..c39924b34e 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -322,15 +322,22 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // Set `reason` here so the durable failure is captured before closeTurn - // appends turn/end. The step number rides along so the operational error's - // location survives in the durable log. - reason = { kind: 'error', step, ...errorData(err) } + // Set the error reason ONLY while the turn is still open — closeTurn appends + // turn/end with it. If the turn has already ended (the only way here: a + // throwing agent/turn-end listener after closeTurn(true) already appended + // turn/end), the reason can no longer affect the durable log, so log the late + // throw directly instead — otherwise the listener exception would vanish. + if (!turnEnded) { + reason = { kind: 'error', step, ...errorData(err) } + } else { + ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`) + } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // contained: the error is already captured on `reason`; a throwing - // agent/error listener must not prevent the turn from closing. + // contained: the error is already captured (on `reason`, or via the logger + // above); a throwing agent/error listener must not prevent the turn from + // closing. } } @@ -609,7 +616,14 @@ async function runStep( let message: Message = assembler.message() message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)) - session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) + // Same content-or-usage guard as the max-tokens branch: a step that finishes + // with neither assembled content nor usage (e.g. a bare `stop` finish that + // streamed nothing) records no assistant/message — an empty-content message + // exists only to host usage, and deriveMessages() skips it either way, so + // appending one with no usage would be a pure trace-only row. + if (message.content.length > 0 || assembler.usage) { + session.append('assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }) + } // --- Tool execution (sequential; parallel execution is a TODO) --- // ToolRegistry.execute converts tool failures (including aborts) into diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8cb60e99b7..d018eff7a2 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -487,6 +487,25 @@ describe('agent loop', () => { expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) }) + it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => { + // A clean `stop` finish that streamed nothing assembled (no blocks) and + // carried no usage chunk has nothing to record: the content-or-usage guard + // on the normal step path suppresses a pure trace-only empty assistant/message. + const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(reasons).toEqual([{ kind: 'completed' }]) + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) + }) + it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => { const callId = CallId('c1') const adapter = new MockAdapter([[ diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 3695904322..ed0900c4a1 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -806,6 +806,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) send(agent, 'go') await waitForIdle(ctx, agent) @@ -815,6 +816,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end) expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error + // The late throw is also logged directly: failTurn's turn-already-ended + // branch warns so a throwing turn-end listener after turn/end never vanishes. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed')) // The whole log is loadable (nothing dropped): a fresh replay sees the turn. const replay = new Session(SessionId('replay'), [...agent.session.events]) expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant']) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 55eeb523a5..5f423f9b93 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -9,7 +9,7 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionId } from './types.ts' +import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts' import { isJsonValue } from './json.ts' @@ -112,7 +112,7 @@ export class Session { // structuredClone can never hit a non-cloneable value here. this.log = seed.map(event => structuredClone(event)) } - this.header = header ?? { version: 1, id, createdAt: Date.now() } + this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } } get events(): readonly SessionEvent[] { @@ -284,7 +284,7 @@ export class SessionStore extends Service { throw new Error(`session cwd must be an absolute path, got "${cwd}"`) } const header: SessionHeader = { - version: 1, + version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: options?.meta?.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ab9159377d..2302b5b94d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -9,6 +9,23 @@ export function SessionId(id: string): SessionId { return id as SessionId } +/** + * The on-disk session format version, stamped into every newly-written + * {@link SessionHeader} and enforced by every persistence backend on load. The + * single source of truth for the version — write sites and the load-time check + * all read it. + * + * It is **`0`** deliberately: while the harness is unreleased the on-disk format + * is **unstable / pre-release, with no compatibility implied**. Breaking changes + * to the persisted {@link SessionEventMap} shape (folding fields onto an event, + * removing a variant, …) happen freely and do NOT bump this — v0 absorbs all + * pre-release churn, and a backend simply REJECTS any log not at v0 (there is no + * migration; no persisted user data exists to preserve). A real, monotonically + * bumped version policy begins at the first tagged release, when a specific + * format boundary becomes worth distinguishing. + */ +export const SESSION_FORMAT_VERSION = 0 + /** * Immutable session metadata — written once at creation and never rewritten. * @@ -19,7 +36,11 @@ export function SessionId(id: string): SessionId { * metadata) writes such a header. */ export interface SessionHeader { - /** On-disk format version; a persistence backend rejects unknown versions. */ + /** + * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the + * session is created. A persistence backend rejects any other version on load + * (no migration — see the constant). + */ version: number /** The session's id (mirrors the {@link Session}'s id). */ id: SessionId diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 075d106948..f095bdfb40 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' describe('Session', () => { it('derives message history from the event log', () => { @@ -255,11 +255,11 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) - it('synthesizes a minimal v1 header for a bare-created session', async () => { + it('synthesizes a minimal current-version header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('plain')) - expect(session.header).toMatchObject({ version: 1, id: 'plain' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' }) expect(typeof session.header.createdAt).toBe('number') expect(session.header.cwd).toBeUndefined() expect(session.header.parentSession).toBeUndefined() @@ -272,7 +272,7 @@ describe('SessionStore', () => { meta: { cwd: '/work/project', parentSession: SessionId('parent') }, }) expect(session.header).toMatchObject({ - version: 1, + version: SESSION_FORMAT_VERSION, id: 'child', cwd: '/work/project', parentSession: 'parent', @@ -288,9 +288,9 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined() }) - it('a bare Session() constructed without the store still exposes a v1 header', () => { + it('a bare Session() constructed without the store still exposes a current-version header', () => { const session = new Session(SessionId('bare')) - expect(session.header).toMatchObject({ version: 1, id: 'bare' }) + expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' }) expect(typeof session.header.createdAt).toBe('number') }) diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 54514755a3..b8df12e547 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Format version.** Only v1 is supported; `load` rejects an unknown version. While the harness is unreleased a format change bumps the version and rejects non-current logs — there is no migration (no persisted user data to preserve). +- **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 8fa02665eb..1df70f9c9a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,7 +249,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('path-traversal session ids are neutralized (no escape from root)', async () => { const evil = SessionId('../../etc/pwn') - const m = { version: 1, id: evil, createdAt: 1 } + const m = { version: 0, id: evil, createdAt: 1 } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(evil, oneTurnLog()) // The file lives UNDER root, not at ../../etc. @@ -310,7 +310,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'g', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' @@ -323,7 +323,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'g2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), @@ -335,7 +335,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'c', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1 }), '{not json', // corrupt, sits in the committed region (a turn/end follows) JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -343,7 +343,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { }) it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => { - const log = JSON.stringify({ type: 'session', version: 1, id: 'h0', createdAt: 1 }) + '\n' + const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1 }) + '\n' const scanned = scanLog(Buffer.from(log)) expect(scanned.events).toEqual([]) // committedBytes falls back to the header line's end (no preserved events). @@ -352,7 +352,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 'c2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' @@ -363,7 +363,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ - JSON.stringify({ type: 'session', version: 1, id: 't', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail @@ -442,7 +442,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // field is tolerated by the header type guard) and confirm list() reads it. const bucket = join(root, '_no-cwd') await mkdir(bucket, { recursive: true }) - const bigHeader = JSON.stringify({ type: 'session', version: 1, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) + const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n') const ids = (await ctx.sessionPersistence.list()).map(x => x.id) expect(ids).toContain('big') diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index d0f8717ff1..7c7b044ee1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,7 +25,7 @@ */ import { Context } from 'cordis' -import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { assertSerializable, seedCoversPrefix } from './index.ts' @@ -319,8 +319,8 @@ export class PersistenceCoordinator { } private assertVersion(meta: SessionHeader): void { - if (meta.version !== 1) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`) + if (meta.version !== SESSION_FORMAT_VERSION) { + throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) } } diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index aa7c76c84c..a0f0e7bfa0 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' @@ -23,7 +23,7 @@ export interface ContractBackend { /** Build a minimal {@link SessionHeader} for a session id. */ export function meta(id: string, cwd?: string): SessionHeader { return { - version: 1, + version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1000, ...cwd !== undefined ? { cwd } : {}, @@ -57,7 +57,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: 2, id: SessionId('v2'), createdAt: 1, cwd: WORK } + const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) @@ -685,7 +685,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const m = { version: 1, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } + const m = { version: SESSION_FORMAT_VERSION, id: SessionId('forked-child'), createdAt: 1, cwd: WORK, parentSession: SessionId('the-parent') } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const loaded = await ctx.sessionPersistence.load(m.id) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 556fb5abff..925881273a 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -33,7 +33,7 @@ const TEXT_CHUNKS: StreamChunk[] = [ /** Build a minimal session-JSONL string: a header line + the given events. */ function sessionJsonl(events: SessionEvent[]): string { - const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 }) + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' } @@ -67,7 +67,7 @@ describe('parseSessionLog', () => { }) it('ignores blank lines', () => { - const header = JSON.stringify({ type: 'session', version: 1, id: 's1', createdAt: 0 }) + const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) const ev = chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk) expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev]) }) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index d254ee8885..a87fa49a9e 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' @@ -167,7 +167,7 @@ describe('acp bridge — session/load replay', () => { loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, + version: SESSION_FORMAT_VERSION, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, }) await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -204,7 +204,7 @@ describe('acp bridge — session/load replay', () => { // to the server's launch dir (the request cwd does not override the header). loader = await makeBridgeHarness({ storageDir, script: [] }) await loader.ctx.sessionPersistence.create({ - version: 1, id: SessionId('legacy'), createdAt: 1, // no cwd + version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd }) await loader.ctx.sessionPersistence.append(SessionId('legacy'), [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, From 2eb6ad3260b2221dc87ccca385662a4617135896 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:33:41 +0800 Subject: [PATCH 76/87] fix review findings: sync stale v1/removed-event doc references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's re-confirmation pass verified both blocker fixes correct but found doc/comment drift the fix commit missed: - session/index.ts + session/README.md: "minimal v1 header" → "minimal header (stamped with the current SESSION_FORMAT_VERSION)" — the version is 0, not 1. - session/index.ts deriveMessages comment listed "usage, and errors" as trace data — those standalone events no longer exist; only boundaries + chunks are. - session-persistence RFC: "no v1 migration" → the pinned-v0 pre-release stance. - collapse-trace-only RFC format-version note: reframed off the "bump the version and reject" wording (which now reads as the OTHER AGENTS.md stance) onto the pinned-0 unstable stance the session log actually uses. - agent-loop/loop.ts finishError JSDoc: "with a logged `error` event" → the failure is recorded on turn/end.reason (no standalone error event). - acp/acp-feature-support.md (two spots): usage is recorded on assistant/message now, not as standalone internal usage events. - Regenerate the cordis catalog (finishError JSDoc line shift). --- .../architecture/2026-06-14-session-persistence.md | 2 +- .../2026-06-20-collapse-trace-only-session-events.md | 2 +- packages/core/agent-loop/src/loop.ts | 4 ++-- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 10 +++++----- packages/ui/acp/acp-feature-support.md | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index a5cbd3bd61..7abe7ec8ed 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -27,7 +27,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. -Format versioning: the header carries a `version`; `load` rejects an unknown version (no v1 migration). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index f0bc1f107f..4ec39bcce4 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -38,6 +38,6 @@ Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposal - **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. -**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`), so per the AGENTS.md "bump the version and reject — don't migrate" policy a backend must reject any non-current log. The version literal is centralized in an exported `SESSION_FORMAT_VERSION` constant (read by both write sites and the coordinator's load-time check). While the harness is unreleased the on-disk format is pre-release/unstable, so the constant stays **`0`**: a breaking format change is absorbed at v0 (no monotonic bump until the first tagged release, when a specific format boundary becomes worth distinguishing) and old logs at any other version are rejected on load — there is no v0→vN migration (no persisted user data exists). `turn/end.reason.error.step` is required for newly-written logs. +**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`). The session log uses the **pinned-`0` "unstable / pre-release"** format stance (one of the two stances AGENTS.md § pre-release sanctions): `SESSION_FORMAT_VERSION` stays `0` and absorbs this and every other pre-release shape change without a monotonic bump — bumping on each tweak would dress up an unstable format as a sequence of stable boundaries that mean nothing yet. The constant is centralized in `dsh-session` and read by both write sites and the coordinator's load-time check, which rejects any non-`0` log (no migration — there is no persisted user data to preserve; a real monotonic policy begins at the first tagged release). `turn/end.reason.error.step` is required for newly-written logs. Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index c39924b34e..8d19c464fd 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -38,8 +38,8 @@ function toError(error: unknown): CodedError { * caller's try/catch), OR end the stream with a finish-error/aborted chunk * (the only option for adapters that can't throw mid-stream, e.g. * library-backed ones). This translates the latter into a thrown step error - * so the turn ends error/aborted with a logged `error` event, never as a - * normal `completed` assistant message. + * so the turn ends error/aborted (the failure recorded on `turn/end.reason`), + * never as a normal `completed` assistant message. * * `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so * the switch handles the known terminal-failure kinds and treats every other diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 74c543fc07..682d5517dd 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -37,7 +37,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). - `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages. - `session.events`, `session.seq`, `session.id` -- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction. +- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. ### Metadata types (`types.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5f423f9b93..cef91c110c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -79,9 +79,10 @@ export class Session { /** * Immutable creation metadata (format version, cwd, lineage). Supplied by * the store via `ctx.sessions.create()`. When a `Session` is constructed - * bare (tests, ad-hoc replay), a minimal v1 header is synthesized so - * `session.header` is always present. Kept out of the event log — it is a - * storage concern, not replayable conversation state. + * bare (tests, ad-hoc replay), a minimal header is synthesized (stamped with + * the current {@link SESSION_FORMAT_VERSION}) so `session.header` is always + * present. Kept out of the event log — it is a storage concern, not + * replayable conversation state. */ readonly header: SessionHeader @@ -180,8 +181,7 @@ export class Session { const messages: Message[] = [] for (const event of this.log) { // Intentionally non-exhaustive: only message-producing events derive - // history; turn/step boundaries, chunks, usage, and errors are - // trace/replay data. + // history; turn/step boundaries and chunks are trace/replay data. // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (event.type) { case 'user/message': { diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 6b171769d3..429c862b27 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -87,7 +87,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | | `config_option_update` | S | ❌ | ✅ | ✅ | No config options. | -| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness HAS usage events internally). | +| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | | `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. | ## 5. Tool-call rendering @@ -148,7 +148,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). 7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). 8. **Diff + location tool rendering** — `diff` content and `locations` for edit tools. -9. **Usage reporting** (`usage_update`) — the harness already has the internal usage events. +9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). 10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. ## Out of scope From e2bde2902cd9e2bed1d539425b433b21fd34964a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:03:44 +0800 Subject: [PATCH 77/87] refactor(examples): extract the app spine into dsh-agent-core + app packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/rfc/.../2026-06-20-extract-example-app-packages.md. Each example was thick — a hand-rolled start.ts, an infra preamble, nested base.yml/base-core.yml/acp-tail.yml includes, and a coupled front-door cluster enforced only by prose. This moves the composition into packages so each example is a thin leaf cordis.yml: pick the swappable backends, load one app package. New packages: - @deepseek-ai/dsh-agent-core (packages/core/agent-core): one bundle plugin that loads the providerless/executor-less/UI-less spine (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop) via ctx.plugin(...) inside apply(), and forwards agent-loop's `agents` list as its own Config (export const Config = AgentLoop.Config, default []). - @deepseek-ai/dsh-stdio-agent (packages/ui/stdio-agent): terminal chat APP — agent-core + console logger + readline UI + a pre-created `main` agent, with a bin. The demo:echo/coding front door. - @deepseek-ai/dsh-acp-agent (packages/ui/acp-agent): ACP server APP — agent-core + JSONL persistence + the acp bridge, NO stdout logger, with a bin. The stdout-purity footgun is structurally unreachable from the leaf. Amendment to the RFC: hmr stays a LEAF cordis.yml entry, not baked into dsh-stdio-agent. hmr is a Loader-only dev plugin (throws without --expose-internals; the in-process test tier can't even import its decorator form), so a package statically importing it 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. With hmr out, all three new packages carry in-process unit specs at 100%. Boot glue (Loader tail, .env load, snapshot-mode selection, stdin-dispose lifecycle) moves into each app's bin; start.ts and base.yml/base-core.yml/ acp-tail.yml are deleted. Each app package gets a keyless real-load-path test that boots through its bin + the cordis Loader (guarding the unwrapExports export-shape bug class, postmortem 0001). ACP snapshot replay stays green against the existing committed goldens (pure boot restructuring). RFC moved proposed->implemented with the amendment recorded; package/example/architecture docs and the module graph updated. --- AGENTS.md | 28 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/module-graph.md | 19 +++ docs/rfc/README.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 53 +++++++ .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 44 ------ .../2026-06-20-providerless-example-base.md | 12 +- examples/AGENTS.md | 2 +- examples/README.md | 21 +-- examples/acp-agent/README.md | 4 +- examples/acp-agent/acp-tail.yml | 33 ---- examples/acp-agent/cordis.snapshot.yml | 53 ++++--- examples/acp-agent/cordis.yml | 66 +++++--- examples/acp-agent/start.ts | 63 -------- examples/acp-agent/tests/acp.e2e.ts | 10 +- examples/acp-agent/tests/snapshot-harness.ts | 9 +- examples/base-core.yml | 37 ----- examples/base.yml | 37 ----- examples/coding-agent/cordis.yml | 92 +++++------ examples/coding-agent/start.ts | 30 ---- .../coding-agent/tests/keyless-smoke.e2e.ts | 23 ++- examples/echo-agent/README.md | 24 +-- examples/echo-agent/cordis.yml | 65 +++----- examples/echo-agent/start.ts | 16 -- examples/echo-agent/tests/echo.e2e.ts | 29 ++-- knip.json | 4 + package.json | 6 +- packages/README.md | 6 + packages/core/README.md | 3 + packages/core/agent-core/README.md | 44 ++++++ packages/core/agent-core/package.json | 46 ++++++ packages/core/agent-core/src/index.ts | 88 +++++++++++ .../core/agent-core/tests/agent-core.spec.ts | 57 +++++++ packages/core/agent-core/tsconfig.json | 42 +++++ packages/ui/README.md | 4 + packages/ui/acp-agent/README.md | 39 +++++ packages/ui/acp-agent/package.json | 47 ++++++ packages/ui/acp-agent/src/bin.ts | 100 ++++++++++++ packages/ui/acp-agent/src/index.ts | 70 +++++++++ packages/ui/acp-agent/tests/acp-agent.spec.ts | 52 +++++++ packages/ui/acp-agent/tests/load-path.e2e.ts | 147 ++++++++++++++++++ packages/ui/acp-agent/tsconfig.json | 30 ++++ packages/ui/acp-agent/tsdown.config.ts | 18 +++ packages/ui/stdio-agent/README.md | 60 +++++++ packages/ui/stdio-agent/package.json | 53 +++++++ packages/ui/stdio-agent/src/bin.ts | 70 +++++++++ packages/ui/stdio-agent/src/index.ts | 98 ++++++++++++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 71 +++++++++ packages/ui/stdio-agent/tsconfig.json | 39 +++++ packages/ui/stdio-agent/tsdown.config.ts | 18 +++ pnpm-lock.yaml | 98 ++++++++++++ tsconfig.build.json | 3 + vitest.config.ts | 7 +- 54 files changed, 1631 insertions(+), 465 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md delete mode 100644 docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md delete mode 100644 examples/acp-agent/acp-tail.yml delete mode 100644 examples/acp-agent/start.ts delete mode 100644 examples/base-core.yml delete mode 100644 examples/base.yml delete mode 100644 examples/coding-agent/start.ts delete mode 100644 examples/echo-agent/start.ts create mode 100644 packages/core/agent-core/README.md create mode 100644 packages/core/agent-core/package.json create mode 100644 packages/core/agent-core/src/index.ts create mode 100644 packages/core/agent-core/tests/agent-core.spec.ts create mode 100644 packages/core/agent-core/tsconfig.json create mode 100644 packages/ui/acp-agent/README.md create mode 100644 packages/ui/acp-agent/package.json create mode 100644 packages/ui/acp-agent/src/bin.ts create mode 100644 packages/ui/acp-agent/src/index.ts create mode 100644 packages/ui/acp-agent/tests/acp-agent.spec.ts create mode 100644 packages/ui/acp-agent/tests/load-path.e2e.ts create mode 100644 packages/ui/acp-agent/tsconfig.json create mode 100644 packages/ui/acp-agent/tsdown.config.ts create mode 100644 packages/ui/stdio-agent/README.md create mode 100644 packages/ui/stdio-agent/package.json create mode 100644 packages/ui/stdio-agent/src/bin.ts create mode 100644 packages/ui/stdio-agent/src/index.ts create mode 100644 packages/ui/stdio-agent/tests/stdio-agent.spec.ts create mode 100644 packages/ui/stdio-agent/tsconfig.json create mode 100644 packages/ui/stdio-agent/tsdown.config.ts diff --git a/AGENTS.md b/AGENTS.md index 2fdd25b552..109fd4fe64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,9 @@ packages/ Harness packages, grouped by role at packages///. tools/ tool registry + tools/execute waterfall agent/ Agent interface, registry, agent/* event vocabulary agent-loop/ THE concrete plugin: ReactLoopAgent + the loop driver + agent-core/ bundle plugin: the providerless/executor-less/UI-less spine + (timer+llm+sessions+system-prompt+tools+agents+invariants+ + tool-bash+agent-loop) as code; forwards agent-loop's `agents` llm/ LLM capability family llm/ abstract LLM service + content-block vocabulary llm-deepseek/ DeepSeek API adapter (hand-rolled fetch/SSE) @@ -67,21 +70,28 @@ packages/ Harness packages, grouped by role at packages///. ui/ product integration surfaces acp/ Agent Client Protocol bridge: drive the agent from an ACP editor (Zed) over JSON-RPC stdio + stdio-agent/ stdio chat APP: agent-core spine + console logger + readline + UI + a pre-created main agent + a bin (the demo:echo/coding + 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) 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) -examples/ Runnable demos (not workspaces; see examples/AGENTS.md). echo-agent - = mock model + echo tool + stdio UI + JSONL persistence, wired via - cordis.yml. coding-agent = the real thing: DeepSeek V4 + bash tools - (pnpm run demo:coding, needs DEEPSEEK_API_KEY). - acp-agent = the coding agent exposed as an ACP server over - JSON-RPC stdio (pnpm run demo:acp, needs DEEPSEEK_API_KEY). - base.yml = shared provider/tool core both real demos include - (= base-core.yml, the providerless core, + the llm-deepseek adapter; - base-core.yml is reused by the acp-agent snapshot-replay config). +examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a + THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter, + a bash executor) and loads ONE app package (dsh-stdio-agent or + dsh-acp-agent), which bundles the agent-core spine + front-door + cluster + boot glue (a bin). No start.ts. echo-agent = mock model + + echo tool on dsh-stdio-agent (pnpm run demo:echo, no key). + coding-agent = the real thing: DeepSeek V4 + bash tools on the same + app (pnpm run demo:coding, needs DEEPSEEK_API_KEY). acp-agent = the + coding agent as an ACP server on dsh-acp-agent (pnpm run demo:acp, + needs DEEPSEEK_API_KEY). cordis.snapshot.yml = the acp leaf with + llm-replay for keyless snapshot replay. docs/ architecture.md — the design doc. module-graph.md — generated inter-package dependency graph (Mermaid; `pnpm run gen-module-graph`). rfc/ — design decisions and proposals, one kind of doc grouped by diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 41fc85be0e..3a9f2c2f1b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -83,4 +83,4 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml` with HMR: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). The two real demos share their provider/tool core via [`examples/base.yml`](../../examples/base.yml). +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle. diff --git a/docs/module-graph.md b/docs/module-graph.md index 92a2e7a09b..9efe6e9669 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -49,6 +49,22 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + agent-core --> agent + agent-core --> agent-loop + agent-core --> invariants + agent-core --> llm + agent-core --> session + agent-core --> system-prompt + agent-core --> tool-bash + agent-core --> tools + acp-agent --> acp + acp-agent --> agent-core + acp-agent --> session-persistence-jsonl + stdio-agent --> agent + stdio-agent --> agent-core + stdio-agent --> session + stdio-agent --> session-persistence-jsonl + stdio-agent --> ui-stdio ``` | Package | Depends on | @@ -72,3 +88,6 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | +| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index b9a5eef295..33efff0340 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 |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | ### Process @@ -115,6 +114,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [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 | ### Process 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 new file mode 100644 index 0000000000..eba2d9501b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -0,0 +1,53 @@ +# RFC: Extract example apps into packages + +Status: implemented + +## Problem + +An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. + +The deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. + +## What shipped + +Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). + +- **`@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. +- **`@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 coupling becomes structurally unreachable from the leaf. 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. +- **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`. + +`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. + +### Amendment on implementation: `hmr` stays a leaf entry + +The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-agent` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: + +1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. +2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function. + +Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app has it, the ACP app structurally cannot. + +## Why not keep the wiring in shared YAML includes? + +The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stayed copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. + +## Verification + +- Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. +- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s. +- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). +- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record. + +## What we give up + +- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight. +- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. + +## Related + +- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. +- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). 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 2f0fc38bf9..0bf983281e 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. -`examples/base.yml` always 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. To avoid duplicating the rest of the tree, the providerless core is factored into `examples/base-core.yml` (shared by `base.yml = base-core + llm-deepseek` and the replay config = `base-core + llm-replay`), and the agent-loop/persistence/ACP-bridge tail into `examples/acp-agent/acp-tail.yml` (shared by `cordis.yml` and the replay config). 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. In replay mode `start.ts` skips `.env` loading 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 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. ### Two surfaces: normalize, then compare diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md deleted file mode 100644 index 5c99e7a197..0000000000 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ /dev/null @@ -1,44 +0,0 @@ -# RFC: Extract example apps into packages - -Status: proposed - -## Problem - -An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. - -The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. - -## Proposal - -Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). - -- **`@deepseek-ai/dsh-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`. This is today's [base-core.yml](../../../../examples/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 (default `[]`, exactly 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 [base-core.yml](../../../../examples/base-core.yml) gives today 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. -- **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. -- **Drop `start.ts`.** Each app package exposes a `bin`; 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 move into that bin, owned by the app. -- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle 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). A handful of entries, no infra preamble. -- **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` 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. -- **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-agent-core`. - -`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. - -## Why not keep the wiring in shared YAML includes? - -The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stays copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. - -## Acceptance criteria - -- Each example directory is `cordis.yml` + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. -- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s. -- `pnpm run test`, `pnpm run test:snapshot` (re-recorded), `pnpm run typecheck`, `pnpm run knip`, `pnpm run publint`, and `pnpm run doc-sync` are green; the new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. - -## What we give up - -- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README must carry that teaching weight. -- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. -- **Migration cost** (the implementing PR, not this one): three new packages, three leaf rewrites, the boot glue moved into bins, re-recorded ACP snapshots, and rewritten example READMEs + [examples/AGENTS.md](../../../../examples/AGENTS.md). - -## Related - -- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. -- Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. -- Complements [Reorganize packages into a modular hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md): the new app/core packages slot into a group under that hierarchy (a product group for the reusable core bundle, or alongside the examples for app-specific wiring). diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index c83824d21c..7856c04b81 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,23 +1,23 @@ # RFC: Make the shared example base providerless -Status: rejected — superseded by [Extract example apps into packages](../../proposed/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem -The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. +The examples had two shared base files: `examples/base-core.yml` was providerless, while `examples/base.yml` included that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result was a naming inversion: the file named `base.yml` was not the reusable base for all examples, while the true base was `base-core.yml`. -The split is understandable, but it makes every config explanation longer. It also leads to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter can boot even though the model is not called. +The split was understandable, but it made every config explanation longer. It also led to awkward test setup like a keyless smoke test carrying a dummy API key so an adapter could boot even though the model is not called. ## Proposal -Rename the providerless core to [examples/base.yml](../../../../examples/base.yml) and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete [examples/base-core.yml](../../../../examples/base-core.yml). +Rename the providerless core to `examples/base.yml` and make adapter selection explicit in each concrete example. The coding and ACP real configs add a tiny `llm-deepseek` include or local block; snapshot config adds `llm-replay`. Delete `examples/base-core.yml`. The shared base should contain only provider-neutral services and tools: `llm`, sessions, system prompt, tools, agents, invariants, bash executor, and bash tool schemas. Anything that chooses a model provider belongs at the leaf config. ## Acceptance criteria -- [examples/base.yml](../../../../examples/base.yml) is providerless. -- [examples/base-core.yml](../../../../examples/base-core.yml) is deleted. +- `examples/base.yml` is providerless. +- `examples/base-core.yml` is deleted. - Real demo configs explicitly add the DeepSeek adapter. - Snapshot replay config includes the same providerless base and its replay adapter. - The [examples README](../../../../examples/README.md), example-specific READMEs, and RFC references stop explaining "base = base-core plus adapter". diff --git a/examples/AGENTS.md b/examples/AGENTS.md index e352679c39..67ea68ae6f 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -2,7 +2,7 @@ Runnable demos that show how the harness is wired. **Examples are NOT workspaces** — each `examples/*/package.json` is a private, dependency-free stub with no build. They are booted as unbuilt `tsx` subprocesses via the cordis Loader reading a `cordis.yml`; the `@deepseek-ai/dsh-*` plugin names in those YAML files resolve through the root `tsconfig.json` `paths` map, not through `node_modules`. -Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: `start.ts`, the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. +Because examples are not under the `packages/*/src` coverage gate, an example that grows real, reusable *logic* should extract it into a `packages/` package (where it gets the per-file 100% gate and a README). Keep only example-specific glue here: the `cordis.yml` wiring, demo-only mocks/teaching artifacts, and the e2e/snapshot scenarios. There is no `start.ts` — the boot glue (Loader tail, `.env` load, snapshot-mode selection, stdin-dispose lifecycle) lives in each app package's `bin` (`@deepseek-ai/dsh-stdio-agent`, `@deepseek-ai/dsh-acp-agent`), which the `demo:*` scripts invoke against the leaf `cordis.yml`. ## Every example ships e2e smokes (keyless + with-key) diff --git a/examples/README.md b/examples/README.md index 59d3f70719..3fd9259e36 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,23 +1,26 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is now a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor) and loads ONE app package, plus any demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent -A mock model + echo tool + stdio UI + JSONL persistence demo. Demonstrates: +A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-agent`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: -- Loading plugins from a `cordis.yml` via `@cordisjs/plugin-loader` + `@cordisjs/plugin-include` +- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-agent` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` -- Persisting session events to JSONL via the `session/event` + `session/flush` pattern -- A minimal stdio UI consuming `agent/stream-chunk` and session events +- "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter -Run with: `pnpm run demo:echo` - -When prompted, type "echo " to trigger a tool call round-trip. +Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. ## coding-agent -The real thing: DeepSeek V4 + the bash tool suite + stdio chat + JSONL persistence, wired from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. +The real thing: DeepSeek V4 + the bash tool suite on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. + +## acp-agent + +The same coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. + +Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index afaf920bfe..3f65a2f354 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,11 +6,11 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) ``` -This boots `@deepseek-ai/dsh-acp` over the shared provider/tool core (`../base.yml`), with `agent-loop` configured with **no pre-created agents** (ACP `session/new` creates them on demand) and JSONL session persistence (so `session/load` works). +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand) plus the two swappable backends (`llm-deepseek`, `bash-local`). The app package bakes in the no-stdout-logger cluster, so the stdout-purity guarantee is a property of the artifact, not a leaf convention. ## stdout is the protocol -This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. Do not add `@cordisjs/plugin-logger-console` or a stdio UI here. Use a stderr exporter if you need logs. +This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` contains no logger entry, so the footgun is structurally unreachable from this leaf. Use a stderr exporter if you need logs. ## Zed configuration diff --git a/examples/acp-agent/acp-tail.yml b/examples/acp-agent/acp-tail.yml deleted file mode 100644 index ce58343add..0000000000 --- a/examples/acp-agent/acp-tail.yml +++ /dev/null @@ -1,33 +0,0 @@ -# The acp-agent "tail" shared by every acp-agent config (the normal demo, the -# snapshot RECORD path which reuses cordis.yml, and the snapshot REPLAY config): -# agent-loop (no pre-created agents — ACP session/new creates them on demand), -# JSONL session persistence, and the ACP bridge with its system prompt. The -# providerless core + an LLM adapter are included BEFORE this tail by each -# config; nothing here loads an adapter, so the tail is provider-agnostic. -# -# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets -# it (so it can harvest / isolate the log), else ./.sessions for the demo. - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -- id: session-persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - -- id: acp - name: '@deepseek-ai/dsh-acp' - config: - model: deepseek-v4-flash - systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. - - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit with - sed or a rewrite. Each bash call runs in a fresh shell — pass workdir - instead of cd. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index b4dfbe21ad..b57920668a 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,32 +1,39 @@ -# Snapshot-test REPLAY config: the acp-agent plugin tree with the model replaced -# by llm-replay (serves a recorded session JSONL — no API key, no network). +# 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. # -# It reuses ../base-core.yml (the providerless core) + ./acp-tail.yml (agent- -# loop + persistence + the ACP bridge), the SAME pieces cordis.yml shares — only -# the LLM adapter differs: llm-replay here, llm-deepseek there. It can't reuse -# ../base.yml because that loads llm-deepseek, whose apply() throws without -# DEEPSEEK_API_KEY, killing a keyless replay run at boot. +# 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 (see -# cordis.yml). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and an -# optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. - -- id: timer - name: '@cordisjs/plugin-timer' - -# Providerless core (everything base.yml has EXCEPT the llm-deepseek adapter). -- id: base-core - name: '@cordisjs/plugin-include' - config: - path: '../base-core.yml' +# 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' -# agent-loop + persistence + the ACP bridge — shared with cordis.yml. -- id: acp-tail - name: '@cordisjs/plugin-include' +# Local bash executor (the agent's only tool, via agent-core's tool-bash schema). +- id: bash + name: '@deepseek-ai/dsh-bash-local' config: - path: './acp-tail.yml' + 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 only tools are bash (plus bash_output/bash_kill for background + tasks). Do ALL file operations through bash: read with cat/sed/head, + search with grep, write with heredocs (cat <<'EOF' > file), edit with + sed or a rewrite. Each bash call runs in a fresh shell — pass workdir + instead of cd. Check the [exit code: N] marker; verify your work. Keep + answers brief and factual. diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 384330cc9d..e456e8ff05 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,30 +1,48 @@ -# The acp-agent plugin tree, loaded via @cordisjs/plugin-include. Also the -# snapshot RECORD config (start.ts selects it for DSH_SNAPSHOT=record): a real -# llm-deepseek run whose persisted log the snapshot harness harvests. +# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config +# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek +# run whose persisted log the snapshot harness harvests. Just the two swappable +# backends — the DeepSeek adapter and the local bash executor — plus the ACP +# server app (@deepseek-ai/dsh-acp-agent), which bundles the agent-core spine, +# JSONL persistence, and the ACP bridge. # -# CRITICAL: this example loads NO stdout logger (no @cordisjs/plugin-logger- -# console, no stdio-chat). stdout is reserved for the ACP JSON-RPC protocol — -# anything else written there corrupts the frames (see packages/acp, RFC 010 § -# Risks). Use a stderr exporter if you need logging. The timer plugin is loaded -# (no stdout writes); hmr is omitted (an editor manages the subprocess). +# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for +# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a +# property of @deepseek-ai/dsh-acp-agent (it contains no logger entry), not a +# leaf convention: there is no logger here to get wrong. # -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — start.ts loads the gitignored repo-root .env first. +# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the +# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). -- id: timer - name: '@cordisjs/plugin-timer' - -# Shared provider/tool core, INCLUDING the real llm-deepseek adapter. Nested -# include resolved relative to THIS file's directory. -- id: base - name: '@cordisjs/plugin-include' +# The DeepSeek adapter. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' config: - path: '../base.yml' + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro -# agent-loop (no pre-created agents) + JSONL persistence + the ACP bridge. -# Shared with the snapshot REPLAY config (cordis.snapshot.yml) so the three -# acp-agent configs don't drift. -- id: acp-tail - name: '@cordisjs/plugin-include' +# Local bash executor (the agent's only tool, via agent-core's tool-bash schema). +- id: bash + name: '@deepseek-ai/dsh-bash-local' config: - path: './acp-tail.yml' + timeoutMs: 60000 + +# The ACP server app: the agent-core spine + JSONL persistence + the ACP bridge. +# Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it +# (so it can harvest / isolate the log), else ./.sessions for the demo. +- 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 only tools are bash (plus bash_output/bash_kill for background + tasks). Do ALL file operations through bash: read with cat/sed/head, + search with grep, write with heredocs (cat <<'EOF' > file), edit with + sed or a rewrite. Each bash call runs in a fresh shell — pass workdir + instead of cd. Check the [exit code: N] marker; verify your work. Keep + answers brief and factual. diff --git a/examples/acp-agent/start.ts b/examples/acp-agent/start.ts deleted file mode 100644 index 11c2769603..0000000000 --- a/examples/acp-agent/start.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' - -// Snapshot-test modes (set by the snapshot harness via env): -// DSH_SNAPSHOT=replay — load cordis.snapshot.yml (providerless; llm-replay -// serves a recorded session log). Skip .env so a stray -// key can never trigger a live model call. -// DSH_SNAPSHOT=record — load the normal cordis.yml (the real llm-deepseek -// adapter + persistence) so a real run can be harvested -// (the persistence root is redirected by env). -// Absent — the normal demo (cordis.yml), driven by a real editor. -const snapshotMode = process.env.DSH_SNAPSHOT -const configPath = snapshotMode === 'replay' ? './cordis.snapshot.yml' : './cordis.yml' - -// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env -// (Node native). Absent file is fine — the environment may already carry them. -// In REPLAY mode we deliberately skip this: replay must never reach the network, -// so we don't want a present .env to enable a live call. -// -// IMPORTANT: this server speaks ACP JSON-RPC on stdout. Do NOT add any -// stdout logging here or in cordis.yml — it would corrupt the protocol frames. -// A present-but-unreadable/malformed .env is a real misconfiguration: surface -// it on STDERR (never stdout) rather than silently running with the wrong env. -if (snapshotMode !== 'replay') { - try { - process.loadEnvFile(new URL('../../.env', import.meta.url).pathname) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`acp-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} - -// Resolve relative cordis.yml paths from the repo root no matter where the -// editor launches this demo command. -process.chdir(fileURLToPath(new URL('../..', import.meta.url))) - -const ctx = new Context() -ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/' - -await ctx.plugin(Loader) -await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { - path: configPath, - }, -}) - -// Graceful shutdown for snapshot runs (both replay and record): when the client -// closes our stdin (it is done driving the session), dispose the whole context. -// Disposal awaits the agent-loop teardown and the persistence backend's final -// `session/flush`, so the session `.jsonl` is fully written before the process -// exits and the harness harvests it (and the subprocess exits cleanly so the -// harness's waitForExit resolves). (In a normal editor session stdin stays open -// for the connection's lifetime; the editor kills the process, so this never -// fires.) -if (snapshotMode !== undefined) { - process.stdin.on('end', () => { - void ctx.fiber.dispose().then(() => { process.exit(0) }) - }) -} diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8dd8af6d01..ee60a7d131 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -26,7 +26,11 @@ import { * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The +// bin resolves its config-path arg from CWD; the subprocess runs from a temp +// workdir, so pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to // a temp workdir (this test launches there and uses it as the session cwd; the // bridge no longer requires cwd === the launch dir, but a temp dir keeps the @@ -55,7 +59,7 @@ interface Spawned { function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { const child = spawn( process.execPath, - ['--import', tsxLoader, startScript], + ['--import', tsxLoader, binScript, configPath], { cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] @@ -101,7 +105,7 @@ describe('acp-agent over real stdio (no key required)', () => { // A dummy key lets the deepseek adapter APPLY (it only checks the key is // present at boot, not valid — the key is used only on a real model call, // which this purity test never triggers). So this runs WITHOUT real creds. - const child = spawn(process.execPath, ['--import', tsxLoader, startScript], { + const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], { cwd: workdir, env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 54d64c3174..7545768b1d 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -31,7 +31,12 @@ import { type SessionNotification, } from '@agentclientprotocol/sdk' -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. +// The bin resolves its config-path arg from CWD and, under DSH_SNAPSHOT=replay, +// swaps it for the sibling cordis.snapshot.yml. The child's cwd is a temp dir +// OUTSIDE the repo, so pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The repo-root tsconfig: dev/test run UNBUILT and the `@deepseek-ai/dsh-*` // imports resolve through its `paths` map. The child's cwd is a temp dir @@ -130,7 +135,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child = spawn( process.execPath, - ['--import', tsxLoader, startScript], + ['--import', tsxLoader, binScript, configPath], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, ) diff --git a/examples/base-core.yml b/examples/base-core.yml deleted file mode 100644 index 15282c8ff6..0000000000 --- a/examples/base-core.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Providerless provider/tool core — everything the model and tools need EXCEPT -# an LLM adapter. Split out of base.yml so two consumers can share it: -# - base.yml = base-core.yml + the real llm-deepseek adapter (the demos). -# - acp-agent/cordis.snapshot.yml = base-core.yml + llm-replay (keyless -# snapshot replay — base.yml can't be reused there because llm-deepseek's -# apply() throws without DEEPSEEK_API_KEY). -# -# Plugin entries use package names (resolved from node_modules), so they are -# insensitive to the baseUrl reset that plugin-include performs per file. - -- id: llm - name: '@deepseek-ai/dsh-llm' - -- id: sessions - name: '@deepseek-ai/dsh-session' - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - -- id: tools - name: '@deepseek-ai/dsh-tools' - -- id: agents - name: '@deepseek-ai/dsh-agent' - -# Dev-mode event-contract assertions + session-log freeze (off in prod). -- id: invariants - name: '@deepseek-ai/dsh-invariants' - -# Bash execution: the local executor implementation + the tool schemas. -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' diff --git a/examples/base.yml b/examples/base.yml deleted file mode 100644 index 897cef725d..0000000000 --- a/examples/base.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Shared provider/tool core for the example agents, loaded via a nested -# @cordisjs/plugin-include from each example's cordis.yml. This is -# base-core.yml (the providerless core: llm, sessions, system-prompt, tools, -# agents, invariants, bash-local, tool-bash) PLUS the real llm-deepseek adapter. -# -# The providerless core lives in base-core.yml so the keyless snapshot-replay -# config (acp-agent/cordis.snapshot.yml) can reuse it with llm-replay in place -# of the adapter — it can't reuse THIS file, because llm-deepseek's apply() -# throws without DEEPSEEK_API_KEY. -# -# Deliberately EXCLUDES: -# - the console logger: it writes to stdout, which the acp-agent reserves for -# the JSON-RPC protocol (see packages/acp). Each example loads logging itself. -# - agent-loop: AgentLoop pre-creates its configured `agents` in its -# constructor, and the examples disagree — coding-agent needs a pre-created -# `main` (its stdio-chat calls ctx.agents.get('main')), while acp-agent must -# pre-create NONE (ACP session/new creates agents on demand). So each example -# declares agent-loop with its own `agents` list. -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the env. - -# The providerless core (resolved relative to THIS file's directory). -- id: base-core - name: '@cordisjs/plugin-include' - config: - path: './base-core.yml' - -# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-flash - - deepseek-v4-pro diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index aa748eb7fd..497aa8896a 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,59 +1,59 @@ -# The coding-agent plugin tree, loaded via @cordisjs/plugin-include. -# Infra (logger/timer/hmr) first, then the shared provider/tool core (nested -# include of ../base.yml), then this example's agent-loop config + UI. +# The coding-agent plugin tree: the real coding agent. The two swappable +# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for +# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio- +# agent), which bundles the whole agent-core spine (timer, llm, sessions, +# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console +# logger, JSONL persistence, the readline UI, and a pre-created `main` agent. # -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — start.ts loads the gitignored repo-root .env first. - -- id: logger - name: '@cordisjs/plugin-logger-console' - -- id: timer - name: '@cordisjs/plugin-timer' +# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only +# dev plugin that needs `--expose-internals` — the `demo:coding` script passes +# it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the +# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env +# first. cordis.yml reads them via the `!!js` tag. +# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr name: '@cordisjs/plugin-hmr' config: root: ['.'] -# Shared provider/tool core (llm, sessions, system-prompt, tools, agents, -# invariants, llm-deepseek, bash-local, tool-bash). Nested include: the path is -# resolved relative to THIS file's directory. -- id: base - name: '@cordisjs/plugin-include' +# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed +# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' config: - path: '../base.yml' + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro -# agent-loop is per-example (NOT in base.yml): coding-agent pre-creates a `main` -# agent its stdio-chat drives via ctx.agents.get('main'). -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +# Local bash executor (the model's only tool, via agent-core's tool-bash schema). +- id: bash + name: '@deepseek-ai/dsh-bash-local' config: - agents: - - id: main - model: deepseek-v4-flash - # Set RESUME_SESSION_ID to continue a prior persisted session (the ids - # live under ./.sessions); unset starts a fresh session each run. - resumeSessionId: !!js process.env.RESUME_SESSION_ID - systemPrompt: | - You are coding-agent, a CLI coding assistant. + timeoutMs: 60000 - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit - with sed or a rewrite. Each bash call runs in a fresh shell — pass - workdir instead of cd, and never rely on shell state between calls. - - Check the [exit code: N] marker on every command; investigate - failures before moving on. Verify your work by running the code or - tests. Keep answers brief and factual. - -- id: session-persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - -- id: stdio-chat - name: '@deepseek-ai/dsh-ui-stdio' +# The stdio chat app: the whole spine + front-door cluster, configured for a +# real coding agent driving a pre-created `main` agent. +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' config: + model: deepseek-v4-flash + # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live + # under ./.sessions); unset starts a fresh session each run. + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).' + systemPrompt: | + You are coding-agent, a CLI coding assistant. + + Your only tools are bash (plus bash_output/bash_kill for background + tasks). Do ALL file operations through bash: read with cat/sed/head, + search with grep, write with heredocs (cat <<'EOF' > file), edit + with sed or a rewrite. Each bash call runs in a fresh shell — pass + workdir instead of cd, and never rely on shell state between calls. + + Check the [exit code: N] marker on every command; investigate + failures before moving on. Verify your work by running the code or + tests. Keep answers brief and factual. diff --git a/examples/coding-agent/start.ts b/examples/coding-agent/start.ts deleted file mode 100644 index 1794b6b510..0000000000 --- a/examples/coding-agent/start.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' - -// Load DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from a gitignored repo-root .env -// (Node >= 21.7 native). Absent file is fine — the environment may already -// carry the variables; cordis.yml reads them via the `!!js` tag. A -// present-but-unreadable/malformed .env is a real misconfiguration: surface it -// rather than silently running with the wrong environment. -try { - process.loadEnvFile(new URL('../../.env', import.meta.url).pathname) -} catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`coding-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. -} - -// Boot a Cordis app from this example's cordis.yml — the same shape as the -// upstream `cordis` bin, pinned to this directory. -const ctx = new Context() -ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/' - -await ctx.plugin(Loader) -await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { - path: './cordis.yml', - }, -}) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index cb8bcb837a..4e5f3e78dc 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -7,21 +7,28 @@ import { afterEach, describe, expect, it } from 'vitest' /** * Keyless Loader-path smoke for examples/coding-agent: boot the REAL example - * through its `cordis.yml` (the cordis Loader, `unwrapExports`, the full plugin - * tree incl. the extracted `@deepseek-ai/dsh-ui-stdio`), then close stdin with - * no prompt and assert the ready banner + a clean exit. + * 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 + * ready banner + a clean exit. * * No prompt is ever sent, so the model is NEVER called — this is why it runs * without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose * `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 shared UI plugin's export shape (a broken - * `export default` that drops `inject` would crash here — see postmortem 0001), - * complementing coding-agent's with-key e2e suites which prove the real product. + * 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 + * product. */ -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-stdio-agent bin (the demo:coding entry) and this example's cordis.yml. +// The bin resolves its config-path arg from CWD; the test spawns from a temp +// cwd, so we pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside @@ -45,7 +52,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { const proc = spawn( process.execPath, // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding). - ['--expose-internals', '--import', tsxLoader, startScript], + ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, env: { diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index a5311e1fd5..de42234ef1 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -1,32 +1,32 @@ # echo-agent -Runnable demo: stdin chat with a scripted mock model and an echo tool. +Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app". ## What it shows -- A complete Cordis app loaded from `cordis.yml` — the standard "stack of plugins" pattern -- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo " -- `echo-tool.ts` — a tool registered via `ctx.tools.register()` that echoes text back uppercased -- `@deepseek-ai/dsh-session-persistence-jsonl` — the durable JSONL persistence backend (loaded from `cordis.yml`, `root: ./.sessions`): append-only event log per session with crash-safe atomic writes, replacing the old write-only example plugin -- `stdio-chat.ts` — a minimal UI plugin: reads stdin lines and `send`/`steer`s the agent, renders stream deltas, tool calls, and tool results +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app (which bundles the whole [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: + +- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. +- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. + +Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `coding-agent` — the same app, a different backend. ## Plugin files | File | Role | Key patterns demonstrated | |---|---|---| -| `mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with proper `block-start`/`block-end` protocol | -| `echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, tool execution returning `ContentBlock[]` | -| `stdio-chat.ts` | UI | `agent/stream-chunk`, `session/event` (tool/*), stdin→send/steer | -| `start.ts` | Bootstrap | `Context` + `Loader` + `plugin-include` wired to `cordis.yml` | +| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol | +| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` | +| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-agent` entry carrying the app config | -Persistence is the shared `@deepseek-ai/dsh-session-persistence-jsonl` plugin (not a per-example file). +The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-agent` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring. ## Run ```sh pnpm run demo:echo # or: -node --expose-internals --import tsx examples/echo-agent/start.ts +node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml ``` Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 71ee1d3841..9eef3d1a1b 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -1,57 +1,38 @@ -# The echo-agent plugin tree, loaded via @cordisjs/plugin-include. -# Core services first, then the demo plugins, then the agent itself. - -- id: logger - name: '@cordisjs/plugin-logger-console' - -- id: timer - name: '@cordisjs/plugin-timer' +# The echo-agent plugin tree: the stdio chat app with its LLM backend swapped to +# the local `mock-echo` mock and the local `echo` tool added. The clean +# demonstration of "swap the backend, keep the app" — every service the agent +# needs lives in @deepseek-ai/dsh-stdio-agent (which bundles @deepseek-ai/dsh- +# agent-core); this leaf only picks the backends, `hmr`, and the app config. +# +# No API key: the `mock-echo` adapter never touches the network. +# Hot-module reload for the dev/demo loop (a leaf entry, not baked into +# dsh-stdio-agent — it needs `node --expose-internals`, which `demo:echo` passes). - id: hmr name: '@cordisjs/plugin-hmr' config: root: ['.'] -- id: llm - name: '@deepseek-ai/dsh-llm' - -- id: sessions - name: '@deepseek-ai/dsh-session' - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - -- id: tools - name: '@deepseek-ai/dsh-tools' - -- id: agents - name: '@deepseek-ai/dsh-agent' - -# Dev-mode event-contract assertions + session-log freeze (off in prod; -# on here so the demo smoke test exercises the contract). -- id: invariants - name: '@deepseek-ai/dsh-invariants' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: - - id: main - model: mock-echo - systemPrompt: 'You are echo-agent, a demo agent.' - +# The mock model (registers the `mock-echo` adapter) and the demo `echo` tool — +# example-local teaching plugins, resolved relative to THIS file's directory. - id: mock-llm name: './src/mock-llm.ts' - id: echo-tool name: './src/echo-tool.ts' -- id: session-persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' +# Local bash executor: agent-core ships the `tool-bash` consumer schema, so the +# leaf provides the executor it runs on (the echo demo doesn't drive bash, but +# the tool is part of the shared spine). +- id: bash + name: '@deepseek-ai/dsh-bash-local' -- id: stdio-chat - name: '@deepseek-ai/dsh-ui-stdio' +# The stdio chat app: console logger + the agent-core spine (pre-creating the +# `main` agent on the mock model) + JSONL persistence + the readline UI. +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' config: + model: mock-echo + systemPrompt: 'You are echo-agent, a demo agent.' welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' + persistenceRoot: './.sessions' diff --git a/examples/echo-agent/start.ts b/examples/echo-agent/start.ts deleted file mode 100644 index 90dba7b5b0..0000000000 --- a/examples/echo-agent/start.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' - -// Boot a Cordis app from this example's cordis.yml — the same shape as the -// upstream `cordis` bin, pinned to this directory. -const ctx = new Context() -ctx.baseUrl = pathToFileURL(import.meta.dirname).href + '/' - -await ctx.plugin(Loader) -await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { - path: './cordis.yml', - }, -}) diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 2e9ef0275c..944a36e429 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -7,22 +7,29 @@ import { afterEach, describe, expect, it } from 'vitest' /** * Keyless Loader-path smoke for examples/echo-agent: boot the REAL example - * through its `cordis.yml` (the cordis Loader, `unwrapExports`, the whole - * plugin tree), pipe a script of stdin lines, and assert the rendered stdout. + * through the `@deepseek-ai/dsh-stdio-agent` bin against this example's + * `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree), + * pipe a script of stdin lines, and assert the rendered stdout. * * This is the guard the per-file unit suite structurally cannot be: it drives - * the extracted `@deepseek-ai/dsh-ui-stdio` plugin 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`) fails here even though hand-mounted unit tests - * stay green (see docs/postmortem/0001). It needs no API key — the `mock-echo` - * adapter never touches the network — so it runs in the default e2e gate. + * 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 + * 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 + * `mock-echo` adapter never touches the network — so it runs in the default e2e + * gate. * * Both branches of mock-llm.ts are exercised: an `echo …` line (the tool * round-trip → `ECHO: …`) and a plain line (the direct canned reply). */ -const startScript = fileURLToPath(new URL('../start.ts', import.meta.url)) +// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml. +// The bin resolves its config-path arg from CWD; the test spawns from a temp +// cwd, so we pass the example config's ABSOLUTE path. +const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root // tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from @@ -53,8 +60,8 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number process.execPath, // --expose-internals: the example's cordis.yml loads the HMR plugin, which // requires it (mirrors the `demo:echo` script). The whole point is to boot - // the example EXACTLY as it really runs, through the Loader. - ['--expose-internals', '--import', tsxLoader, startScript], + // the example EXACTLY as it really runs, through the bin + Loader. + ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, ) child = proc diff --git a/knip.json b/knip.json index 6699203570..e73d165138 100644 --- a/knip.json +++ b/knip.json @@ -28,6 +28,10 @@ "packages/llm/llm-pi-ai": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/ui/acp-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/package.json b/package.json index 499cffaf77..7a0b770d1e 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", - "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", - "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", - "demo:acp": "node --expose-internals --import tsx examples/acp-agent/start.ts", + "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", + "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", + "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { diff --git a/packages/README.md b/packages/README.md index 139050683d..d5e1f55fe6 100644 --- a/packages/README.md +++ b/packages/README.md @@ -35,6 +35,9 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge) dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) +dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) +dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) +dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` The rule: 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. 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)). @@ -49,6 +52,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l | `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` | | `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) | | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | @@ -59,6 +63,8 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l | `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) | | `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | | `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`) | diff --git a/packages/core/README.md b/packages/core/README.md index 9c5411fd5f..8d8805471a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,5 +9,8 @@ The packages every harness build is assembled from: the session log, the system- | `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) | `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. + +`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md new file mode 100644 index 0000000000..1357a6ce8a --- /dev/null +++ b/packages/core/agent-core/README.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-agent-core + +The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. + +This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle. + +## The tree it loads + +`apply(ctx, config)` mounts each of these as a child of the bundle fiber: + +``` +@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary +@deepseek-ai/dsh-session event-sourced session log + store +@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly +@deepseek-ai/dsh-tools tool registry + tools/execute waterfall +@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary +@deepseek-ai/dsh-invariants dev-mode event-contract assertions +@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas +@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) +``` + +## What it deliberately leaves OUTSIDE the bundle + +The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: + +- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). +- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). +- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). + +This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. + +## Config + +```ts +import type { Config } from '@deepseek-ai/dsh-agent-core' +// Config === AgentLoop.Config — the `agents` list, default []. +``` + +The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create. + +## Why a code bundle, not a shared YAML include + +A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact. Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json new file mode 100644 index 0000000000..d6e716835b --- /dev/null +++ b/packages/core/agent-core/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-agent-core", + "description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-timer": "^1.1.2", + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tool-bash": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts new file mode 100644 index 0000000000..ad3f5d8c46 --- /dev/null +++ b/packages/core/agent-core/src/index.ts @@ -0,0 +1,88 @@ +/** + * The providerless, executor-less, UI-less agent spine as ONE bundle plugin. + * + * Loads the fixed set of services every harness agent needs — `timer`, the LLM + * service, the session store, system-prompt assembly, the tool registry, the + * agent registry, the dev-mode invariants, the model-facing `bash` tool + * schemas, and the concrete `agent-loop` — and forwards the loop's `agents` + * list as its OWN config (default `[]`), so each app supplies its own + * pre-created agents. + * + * It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the + * bundle, picked by whatever loads it. + * - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle + * ships the abstract `llm` service + `tool-bash` consumer schema; the leaf + * registers a concrete adapter on `ctx.llm`. + * - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships + * the `bash` tool consumer; the leaf provides `ctx.bash`. + * - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra + * (a console logger, `hmr`) — these are the coupled "front-door cluster" the + * app packages ({@link @deepseek-ai/dsh-stdio-agent}, + * {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine. + * + * This is the interface/implementation/consumer seam at the composition level: + * the bundle owns the shared spine, the leaf owns the backends, the app package + * owns the front door. `timer` is in the spine (common to every front door — it + * writes nothing to stdout); the console logger is NOT (it writes to stdout, + * which the ACP bridge reserves for its JSON-RPC channel). + * + * Services register in the root store keyed by their isolate symbol, so a child + * loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the + * leaf's adapter and executor) exactly as a nested `plugin-include` subtree's + * services were before this bundle existed — cordis gates every read on + * `inject`, never on load order, so the fixed child set resolves regardless of + * which entry loads first. + * + * 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` function and drop the + * `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in + * the app packages guard this end-to-end. + * + * @module @deepseek-ai/dsh-agent-core + */ + +import type { Context } from 'cordis' +import Timer from '@cordisjs/plugin-timer' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import * as invariants from '@deepseek-ai/dsh-invariants' +import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' + +export const name = 'agent-core' + +/** + * Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]` + * — an app that pre-creates no agents (the ACP bridge creates them on demand at + * `session/new`) simply omits it; an app that needs a pre-created `main` (the + * stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and + * the forwarded shape can never drift. + */ +export type Config = AgentLoopConfig + +/** Forward the loop's own schema so validation + defaulting stay identical. */ +export const Config = AgentLoop.Config + +/** + * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; + * `agent-loop` receives the forwarded `agents` list. Load order is irrelevant + * (cordis pends each fiber on its `inject` until the services it needs exist), + * but the listing mirrors the dependency layering for readability: the LLM + * vocabulary and core registries first, then the dev tripwire and the bash tool + * consumer, then the loop that drives them. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(Timer) + ctx.plugin(LlmService) + ctx.plugin(SessionStore) + ctx.plugin(SystemPrompt) + ctx.plugin(ToolRegistry) + ctx.plugin(AgentRegistry) + ctx.plugin(invariants) + ctx.plugin(toolBash) + ctx.plugin(AgentLoop, { agents: config.agents }) +} diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts new file mode 100644 index 0000000000..f42f9b399d --- /dev/null +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as agentCore from '../src/index.ts' +import { AgentId } from '@deepseek-ai/dsh-agent' + +/** + * Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings + * up the whole providerless spine in one `ctx.plugin`, and the forwarded + * `agents` config reaches the loop (default `[]`, or a pre-created agent). + * + * The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE + * import, the same shape the Loader builds from `unwrapExports`. The real + * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless + * bin smokes; here we assert the composition + config forwarding. + */ +async function mount(config?: agentCore.Config): Promise { + const ctx = new Context() + await ctx.plugin(agentCore, config) + // The bundle mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services and any pre-created agent are ready. + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +describe('dsh-agent-core bundle', () => { + it('brings up the full providerless spine', async () => { + const ctx = await mount() + // One service from each layer of the spine proves the children loaded. + expect(ctx.get('timer')).toBeDefined() + expect(ctx.get('llm')).toBeDefined() + expect(ctx.get('sessions')).toBeDefined() + expect(ctx.get('systemPrompt')).toBeDefined() + expect(ctx.get('tools')).toBeDefined() + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('defaults the agents list to empty (no pre-created agents)', async () => { + const ctx = await mount() + expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('forwards a pre-created agent to the loop', async () => { + const ctx = await mount({ + agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }], + }) + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('re-exports the loop config schema as its own', () => { + expect(agentCore.Config).toBeDefined() + expect(agentCore.name).toBe('agent-core') + }) +}) diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json new file mode 100644 index 0000000000..3cf1e3fb74 --- /dev/null +++ b/packages/core/agent-core/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/timer" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/agent-loop" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../bash/tool-bash" + } + ] +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 62b2c70855..075dfd524d 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,5 +5,9 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `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. + +`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 just the swappable backends plus one app entry. 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/acp-agent/README.md b/packages/ui/acp-agent/README.md new file mode 100644 index 0000000000..38894f3146 --- /dev/null +++ b/packages/ui/acp-agent/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-acp-agent + +The **ACP server app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. + +It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. + +## What it bakes in — and what it deliberately omits + +stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it LEAVES OUT as what it includes: + +| Plugin | Why | +|---|---| +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | +| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC | +| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | +| ~~`hmr`~~ | **omitted** — the editor owns the subprocess | + +Because there is no logger entry in the package, the footgun is **structurally unreachable from the leaf**: a leaf author cannot wire a stdout logger into the ACP config, because the leaf only picks backends, not the front door. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `model` | (required) | the per-session agent template the bridge creates agents from | +| `systemPrompt` | (required) | the per-session agent's system prompt | +| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | + +The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). + +## The bin + +`dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`): + +- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call; +- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); +- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit. + +All diagnostics go to **stderr** — stdout is the protocol. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json new file mode 100644 index 0000000000..0ecbba9e6a --- /dev/null +++ b/packages/ui/acp-agent/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-acp-agent", + "description": "ACP server app: the agent-core spine + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "bin": { + "dsh-acp-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-acp": "^0.0.1", + "@deepseek-ai/dsh-agent-core": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "cordis": "^4.0.0-rc.6", + "schemastery": "^3.17.0" + } +} diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts new file mode 100644 index 0000000000..00a8bfdec0 --- /dev/null +++ b/packages/ui/acp-agent/src/bin.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * 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. + * + * 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. + * + * 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. + * + * 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' + +/** + * 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) +} + +/** + * 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. + } +} + +/** + * Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the + * config's directory and the include gets only the basename, so the config's + * relative plugin/include paths resolve as the upstream `cordis` bin does. + * Returns the root context. + */ +export async function boot(absoluteConfigPath: string): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + await ctx.plugin(Loader) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: `./${basename(absoluteConfigPath)}` }, + }) + return ctx +} + +/** + * Entry point. 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 { + 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/src/index.ts b/packages/ui/acp-agent/src/index.ts new file mode 100644 index 0000000000..0ff3609fbb --- /dev/null +++ b/packages/ui/acp-agent/src/index.ts @@ -0,0 +1,70 @@ +/** + * The ACP server app: the providerless agent spine ({@link + * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP + * server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp} + * bridge, and DELIBERATELY NOTHING that writes to stdout. + * + * The cluster is the OPPOSITE of {@link @deepseek-ai/dsh-stdio-agent}'s, and + * baking it in is the whole point: an ACP server speaks JSON-RPC on stdout, so + * a stray console logger would corrupt the protocol frames (the [stdout-purity + * footgun]). This package contains NO console-logger entry, NO `hmr` (the editor + * owns the subprocess), and pre-creates NO agents (ACP `session/new` creates + * them on demand) — so the footgun is structurally unreachable from the leaf: + * there is no logger entry to get wrong. + * + * The leaf supplies only the swappable backends: the LLM adapter (`llm-deepseek` + * for the real model, `llm-replay` for keyless snapshot replay) and the bash + * executor (`bash-local`). This app's {@link Config} (model, system prompt, + * persistence root) routes each value to where it is wired — model/prompt onto + * the bridge's per-session agent template, the root onto the JSONL backend. + * + * 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 exact bug that shipped here once). + * The keyless ACP snapshot/Loader-path tests guard this end-to-end. + * + * @module @deepseek-ai/dsh-acp-agent + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import * as acp from '@deepseek-ai/dsh-acp' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +export const name = 'acp-agent' + +/** + * App config: the swappable per-deployment values. `model`/`systemPrompt` + * configure the agent template the ACP bridge creates each session's agent from + * (NOT a pre-created agent — ACP creates agents at `session/new`); + * `persistenceRoot` is the JSONL backend's directory. + */ +export interface Config { + /** Model name for ACP-created agents (must have a registered adapter). */ + model: string + /** Per-agent system prompt for ACP-created agents. */ + systemPrompt: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string +} + +export const Config: z = z.object({ + model: z.string().required(), + systemPrompt: z.string().required(), + persistenceRoot: z.string().default('./.sessions'), +}) + +/** + * Compose the spine with the ACP front door. The agent-core bundle pre-creates + * NO agents (its `agents` list defaults to `[]`); the JSONL backend persists + * under `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates + * one agent per `session/new` from `model`/`systemPrompt`. No logger, no `hmr` — + * stdout stays pure. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(agentCore) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt }) +} diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts new file mode 100644 index 0000000000..227b4c67b9 --- /dev/null +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as acpAgent from '../src/index.ts' + +/** + * In-process unit coverage for the @deepseek-ai/dsh-acp-agent composition: + * mounting it brings up the agent-core spine + JSONL persistence + the ACP + * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO + * Loader-only plugin (no hmr), so it mounts in a plain Context. + * + * The REAL Loader-path guard (export shape via `unwrapExports`, the headline + * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`; + * this spec asserts the composition and the persistenceRoot default branch. + */ +async function mount(config: acpAgent.Config): Promise { + const ctx = new Context() + await ctx.plugin(acpAgent, config) + // The bundle mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services are ready. + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +describe('dsh-acp-agent composition', () => { + it('brings up the spine + persistence + the ACP bridge', async () => { + const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' }) + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('sessions')).toBeDefined() + expect(ctx.get('sessionPersistence')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + // No pre-created agents — ACP session/new creates them on demand. + expect(ctx.get('agents')!.list()).toHaveLength(0) + await ctx.fiber.dispose() + }) + + it('defaults the persistence root when omitted', async () => { + // Exercises the `?? './.sessions'` fallback for a direct-apply caller that + // bypasses the schema's `.default(...)`: call `apply` directly (not via + // `ctx.plugin`, which validates+defaults the config first) with no + // persistenceRoot, so the runtime fallback is the one that fires. + const ctx = new Context() + acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(ctx.get('sessionPersistence')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('exposes its plugin shape', () => { + expect(acpAgent.name).toBe('acp-agent') + expect(acpAgent.Config).toBeDefined() + }) +}) diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/ui/acp-agent/tests/load-path.e2e.ts new file mode 100644 index 0000000000..fd559d99bf --- /dev/null +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -0,0 +1,147 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { Readable, Writable } from 'node:stream' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' + +/** + * REAL-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its + * own `bin` (the demo:acp entry) as a subprocess, driving the cordis Loader and + * `unwrapExports` over a minimal `cordis.yml` that loads THIS package. This is + * the guard a hand-built `ctx.plugin({...})` mount structurally cannot be — that + * bypasses `unwrapExports`, the exact path that once dropped the bridge's + * `inject` and shipped (docs/postmortem/0001). It exercises the headline ACP + * operations end-to-end: `initialize` → `session/new` → `session/load`. + * + * KEYLESS: `session/new` and `session/load` reach the agent FACTORY but never + * the model (no prompt is sent), so no DEEPSEEK_API_KEY is needed. A dummy key + * lets `llm-deepseek`'s `apply()` (key-PRESENT check only) boot the tree. + * + * The config is written into a temp dir whose cwd IS the session workspace, so + * the bash workdir validation passes. We point tsx at the repo-root tsconfig + * (TSX_TSCONFIG_PATH) because the child's cwd is outside the repo and the + * unbuilt `paths` map is found by searching UP from cwd. + */ + +const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +// Repo root is four levels up from packages/ui/acp-agent/tests. +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +// A minimal leaf that loads this app + the two backends — the same shape as +// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture. +const CORDIS_YML = ` +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: [deepseek-v4-flash] +- id: bash + name: '@deepseek-ai/dsh-bash-local' +- id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + systemPrompt: 'You are a test agent.' +` + +interface Spawned { + child: ChildProcessWithoutNullStreams + client: ClientSideConnection + stderr: string[] +} + +let spawned: Spawned | undefined +let workdir: string | undefined + +afterEach(async () => { + if (spawned !== undefined) { + spawned.child.kill('SIGKILL') + spawned = undefined + } + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function boot(): Promise { + workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-')) + const cwd = workdir + const configPath = join(cwd, 'cordis.yml') + await writeFile(configPath, CORDIS_YML) + const child = spawn( + process.execPath, + ['--import', tsxLoader, binScript, configPath], + { + cwd, + env: { + ...process.env, + TSX_TSCONFIG_PATH: repoTsconfig, + // Key-present check only; no prompt is sent, so the model is never called. + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + const stderr: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderr.push(chunk)) + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(_params: SessionNotification): Promise { + return Promise.resolve() + }, + requestPermission(_params: RequestPermissionRequest): Promise { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + const client = new ClientSideConnection(makeClient, stream) + spawned = { child, client, stderr } + return { ...spawned, cwd } +} + +describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => { + it('boots via its bin and answers initialize → session/new → session/load', async () => { + const { client, cwd, stderr } = await boot() + // initialize: a broken export shape (collapsed bridge plugin, dropped inject) + // crashes the tree on the first service read here — see postmortem 0001. + const init = await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }) + expect(init.agentCapabilities?.loadSession).toBe(true) + + // session/new reaches the agent FACTORY (create) without the model. + const { sessionId } = await client.newSession({ cwd, mcpServers: [] }) + expect(sessionId).toBeTruthy() + + // session/load reaches the resume FACTORY + persistence without the model: + // load an UNKNOWN id (loading the live `sessionId` would correctly reject as + // "already loaded"). The bridge consults `sessionPersistence.list()` then + // `agents.resume()`, both of which run from the JSON-RPC read loop OUTSIDE + // the bridge's inject scope — the exact path postmortem 0001 crashed. A + // healthy tree rejects with a not-found error; a broken export shape would + // instead throw "cannot get property … without inject" before reaching it. + const unknownId = '00000000-0000-4000-8000-000000000000' + await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then( + () => { throw new Error('expected session/load of an unknown id to reject') }, + (error: unknown) => { expect(String(error)).not.toContain('without inject') }, + ) + + expect(stderr.join('')).not.toContain('without inject') + }, 30_000) +}) diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json new file mode 100644 index 0000000000..773ca2e293 --- /dev/null +++ b/packages/ui/acp-agent/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../acp" + }, + { + "path": "../../core/agent-core" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + } + ] +} diff --git a/packages/ui/acp-agent/tsdown.config.ts b/packages/ui/acp-agent/tsdown.config.ts new file mode 100644 index 0000000000..a0710d6e4d --- /dev/null +++ b/packages/ui/acp-agent/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`), + * the latter referenced by package.json `bin`/`exports["./bin"]`. The root + * tsdown builds only `src/index.ts`, so this override adds `bin.ts`. + * Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/bin.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md new file mode 100644 index 0000000000..286fe6a85b --- /dev/null +++ b/packages/ui/stdio-agent/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-stdio-agent + +The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. + +It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. + +## What it bakes in + +A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it: + +| Plugin | Why it is here | +|---|---| +| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`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 | + +`@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:coding` leaves load it and pass `--expose-internals`. + +The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `model` | (required) | the pre-created `main` agent's model | +| `systemPrompt` | (required) | the `main` agent's system prompt | +| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | +| `welcome` | `ready.` | the stdin-chat banner | +| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | + +## The bin + +`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config — the boot glue the `examples/*/start.ts` files once each duplicated. The `demo:echo` / `demo:coding` scripts invoke it. + +## Example leaf `cordis.yml` + +```yaml +# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app. +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: [deepseek-v4-flash] +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash + systemPrompt: 'You are a CLI coding assistant. Your only tools are bash…' +``` + +Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json new file mode 100644 index 0000000000..45e8021607 --- /dev/null +++ b/packages/ui/stdio-agent/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-stdio-agent", + "description": "Terminal stdio chat app: the agent-core spine + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "bin": { + "dsh-stdio-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@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-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" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-logger-console": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@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/bin.ts b/packages/ui/stdio-agent/src/bin.ts new file mode 100644 index 0000000000..015a462f52 --- /dev/null +++ b/packages/ui/stdio-agent/src/bin.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * 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`). + * + * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding` + * scripts invoke it with the example's config. + * + * @module @deepseek-ai/dsh-stdio-agent/bin + */ + +import { pathToFileURL } from 'node:url' +import { basename, dirname, resolve } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +/** + * 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. + } +} + +/** + * Boot the Loader against `configPath` (resolved from the CWD). `baseUrl` is + * pinned to the config's directory and the include is handed only the basename, + * so the config's relative plugin/include paths resolve exactly as the upstream + * `cordis` bin does. Returns the root context (the process owns its lifetime). + */ +export async function boot(configPath: string): Promise { + 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: `./${basename(absolute)}` }, + }) + return ctx +} + +/** + * Entry point: 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 { + 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 stop */ diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts new file mode 100644 index 0000000000..c4b9ed202c --- /dev/null +++ b/packages/ui/stdio-agent/src/index.ts @@ -0,0 +1,98 @@ +/** + * 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 + * 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 only the swappable backends (the LLM + * adapter, the bash executor), the optional `hmr` dev-reload plugin, and this + * app's {@link Config} (model, prompt, persistence root, welcome banner). + * + * `hmr` is deliberately a LEAF entry, not baked in here: it is a Loader-only, + * subprocess-only dev plugin (its constructor throws without `--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 be unit-tested or + * 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, while + * baking the LOGGER in (the real coupling) keeps stdout-vs-no-stdout a property + * of the artifact. + * + * Counterpart to {@link @deepseek-ai/dsh-acp-agent}, which bakes in the OPPOSITE + * cluster (no stdout logger, no pre-created agents — the ACP bridge reserves + * stdout for JSON-RPC and creates agents on demand). Splitting the two front + * doors into two packages makes each cluster a property of the artifact: there + * is no logger entry in the ACP leaf to get wrong. + * + * 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. + * + * @module @deepseek-ai/dsh-stdio-agent + */ + +import type { Context } from 'cordis' +import ConsoleExporter from '@cordisjs/plugin-logger-console' +import z from 'schemastery' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * 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' + +export const name = 'stdio-agent' + +/** + * App config: the swappable per-demo values, each routed to where the app wires + * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main` + * agent (through {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); + * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner. + */ +export interface Config { + /** Model name for the `main` agent (must have a registered adapter). */ + model: string + /** System prompt for the `main` agent. */ + systemPrompt: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ + welcome?: string + /** + * If set, the `main` agent RESUMES this persisted session id instead of + * starting fresh. Sourced from an env var in the leaf `cordis.yml` + * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). + */ + resumeSessionId?: string +} + +export const Config: z = z.object({ + model: z.string().required(), + systemPrompt: z.string().required(), + persistenceRoot: z.string().default('./.sessions'), + welcome: z.string().default('ready.'), + resumeSessionId: z.string(), +}) + +/** + * 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 + * concern (see the module doc), so it is not mounted here. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(ConsoleExporter) + ctx.plugin(agentCore, { + agents: [{ + id: AgentId('main'), + model: config.model, + systemPrompt: config.systemPrompt, + ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + }], + }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) +} diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts new file mode 100644 index 0000000000..ab095bda66 --- /dev/null +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import * as stdioAgent from '../src/index.ts' + +/** + * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it + * composes the console logger, the agent-core spine (pre-creating the `main` + * agent from the app config), the JSONL backend, and the readline UI in one + * `ctx.plugin`. The forwarded `model`/`systemPrompt` reach the pre-created + * 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 + * forwarding the unit tier can reach. + */ +async function mount(config: stdioAgent.Config): Promise { + const ctx = new Context() + await ctx.plugin(stdioAgent, config) + // The app mounts its children inside apply() (not awaited there); let their + // fibers settle so the spine services + the pre-created agent are ready. + await new Promise(resolve => setTimeout(resolve, 80)) + return ctx +} + +describe('dsh-stdio-agent app', () => { + it('composes the spine + front-door cluster and pre-creates the main agent', async () => { + const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' }) + // The spine services (brought up by the agent-core bundle) are all present. + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('sessionPersistence')).toBeDefined() + // The pre-created `main` agent the UI drives. + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('defaults persistenceRoot and welcome when omitted', async () => { + // Direct apply (NOT via ctx.plugin, which validates+defaults the config + // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on + // apply()'s last two lines are the ones that fire — covering a + // schema-bypassing direct-mount caller. + const ctx = new Context() + stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' }) + await new Promise(resolve => setTimeout(resolve, 80)) + expect(ctx.get('sessionPersistence')).toBeDefined() + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + + it('forwards resumeSessionId onto the pre-created agent when set', async () => { + // A resume id defers agent creation until persistence loads; with no backing + // session the resume is contained + logged, so no `main` agent registers — + // the branch that maps resumeSessionId through is what this covers. + const ctx = await mount({ + model: 'mock', + systemPrompt: 'hi', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', + resumeSessionId: 'no-such-session', + }) + expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('exposes its name and Config schema', () => { + expect(stdioAgent.name).toBe('stdio-agent') + expect(stdioAgent.Config).toBeDefined() + }) +}) diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json new file mode 100644 index 0000000000..2130a6162c --- /dev/null +++ b/packages/ui/stdio-agent/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/logger-console" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent-core" + }, + { + "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/ui-stdio" + } + ] +} diff --git a/packages/ui/stdio-agent/tsdown.config.ts b/packages/ui/stdio-agent/tsdown.config.ts new file mode 100644 index 0000000000..62dc986c08 --- /dev/null +++ b/packages/ui/stdio-agent/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` + * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. + * The root tsdown builds only `src/index.ts`, so this override adds `bin.ts`. + * Declarations come from `tsc -b` (dts: false), matching every package. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/bin.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d0f6ec270..3bca330308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,39 @@ 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/core/agent-core: + devDependencies: + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/agent-loop: dependencies: schemastery: @@ -377,6 +410,63 @@ 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/ui/acp-agent: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../acp + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + + packages/ui/stdio-agent: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@cordisjs/plugin-logger-console': + specifier: workspace:^ + version: link:../../../vendor/logger-console + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@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) + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + packages/util/brand: devDependencies: cordis: @@ -3893,6 +3983,14 @@ snapshots: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': link:vendor/include + '@cordisjs/plugin-loader': link:vendor/loader + cosmokit@1.8.1: {} cross-spawn@7.0.6: diff --git a/tsconfig.build.json b/tsconfig.build.json index c9b377ab0d..27a17a3f17 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -20,6 +20,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/core/tools" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/core/agent-core" }, { "path": "./packages/bash/bash" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, @@ -27,6 +28,8 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, + { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/ui-stdio" }, { "path": "./packages/support/llm-replay" } ] diff --git a/vitest.config.ts b/vitest.config.ts index 91257ea2bd..55ec8477ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,12 @@ export default defineConfig({ // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). include: ['packages/*/*/src/**/*.ts'], - exclude: ['packages/*/*/src/types.ts'], + // Types-only files carry no executable code. `bin.ts` files are + // self-executing CLI entrypoints (a top-level `await main()`): a spec + // can't import one without booting it, so they are driven by the keyless + // Loader-path smoke (a real subprocess) instead of the in-process unit + // suite — the same reason `examples/start.ts` sat out of coverage scope. + exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts'], // 100% or it doesn't merge (AGENTS.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. // Every v8 ignore comment must carry a reason — see AGENTS.md. From 66e56bd395bd27ebfa1090068a442963562505cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:49:59 +0800 Subject: [PATCH 78/87] fix review findings: stale coding-agent README, export-shape guards, overclaim wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of PR #88 found three issues in the example-app extraction: A1 — examples/coding-agent/README.md's plugin table still listed the OLD direct-wired leaf entries (agent-loop, session-persistence, src/stdio-chat.ts — the whole src/ dir is gone). Rewrite it to the four real leaf entries the current cordis.yml loads (hmr, llm-deepseek, bash, stdio-agent), noting that tool-bash/persistence/agent/loop now live inside the agent-core + stdio-agent bundles. A2 — the three new app/spine packages (agent-core, stdio-agent, acp-agent) export NO `inject`, so a stray `export default apply` would let unwrapExports collapse the module and silently DROP name/Config WITHOUT crashing — the real-load-path smokes would stay green. agent-core is never Loader-unwrapped at all. Add an explicit export-shape guard per package: assert no `default` export and that the real Loader.unwrapExports leaves name/Config/apply intact. Verified each fails when `export default apply` is added. B — soften "structurally unreachable / cannot wire a stdout logger" overclaims in the acp-agent/agent-core READMEs and the implemented RFC: a leaf CAN still add a sibling logger entry; the accurate claim is the app omits one so the default leaf has nothing to get wrong. Keep the safety directive (never add a stdout logger to an ACP leaf). --- ...2026-06-20-extract-example-app-packages.md | 4 ++-- examples/acp-agent/README.md | 4 ++-- examples/coding-agent/README.md | 11 +++++----- packages/core/agent-core/README.md | 2 +- .../core/agent-core/tests/agent-core.spec.ts | 22 +++++++++++++++++++ packages/ui/acp-agent/README.md | 2 +- packages/ui/acp-agent/tests/acp-agent.spec.ts | 21 ++++++++++++++++++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 21 ++++++++++++++++++ 8 files changed, 76 insertions(+), 11 deletions(-) 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 eba2d9501b..6ab2c6a15f 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 @@ -13,7 +13,7 @@ The deeper problem was a **coupled front-door cluster** that lived at the leaf w Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). - **`@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. -- **`@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 coupling becomes structurally unreachable from the leaf. They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. +- **`@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. - **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. @@ -28,7 +28,7 @@ The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Val 1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. 2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function. -Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app has it, the ACP app structurally cannot. +Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app includes it, the ACP app omits it. ## Why not keep the wiring in shared YAML includes? diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 3f65a2f354..1df36715ec 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -6,11 +6,11 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand) plus the two swappable backends (`llm-deepseek`, `bash-local`). The app package bakes in the no-stdout-logger cluster, so the stdout-purity guarantee is a property of the artifact, not a leaf convention. +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand) plus the two swappable backends (`llm-deepseek`, `bash-local`). The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. ## stdout is the protocol -This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` contains no logger entry, so the footgun is structurally unreachable from this leaf. Use a stderr exporter if you need logs. +This example loads **no stdout logger** — `stdout` carries the JSON-RPC frames, and any other write corrupts them. `@deepseek-ai/dsh-acp-agent` includes no logger entry, so this leaf has none to get wrong by default; do not add one (use a stderr exporter if you need logs). ## Zed configuration diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 44445adc5d..7585129382 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -32,15 +32,16 @@ RESUME_SESSION_ID= pnpm run demo:coding The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. -## What each plugin demonstrates +## What each leaf entry demonstrates + +This example is a thin leaf `cordis.yml`: it picks the swappable backends and loads one app package. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) all live inside the [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent) app and the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle it loads — so the leaf has only four entries: | Entry | Demonstrates | |---|---| +| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:coding` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | -| `bash` (`dsh-bash-local`) + `tool-bash` | the executor seam + tool schemas as separate plugins | -| `agent-loop` | agent created from config with a coding system prompt | -| `session-persistence` (`dsh-session-persistence-jsonl`) | durable JSONL persistence (`root: ./.sessions`): append-only event log per session, crash-safe atomic writes — the shared backend, no per-example file | -| `src/stdio-chat.ts` | UI as a plugin; copied from echo-agent with reasoning-dimming and an exit-on-idle close handler for piped stdin. Example-local on purpose — extract a shared UI package when a third example needs it | +| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice | +| `stdio-agent` (`@deepseek-ai/dsh-stdio-agent`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | ## End-to-end tests (`pnpm run test:e2e`, key-gated) diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 1357a6ce8a..28a3592ac6 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -41,4 +41,4 @@ The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so e ## Why a code bundle, not a shared YAML include -A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact. Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. +A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, means the default leaf for an ACP server has no logger entry to copy wrong — "the ACP app never logs to stdout" stops being a prose warning a leaf must remember and becomes the app package's default shape (a leaf can still add a sibling logger, so the rule stays documented — but it has nothing to get wrong by default). Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index f42f9b399d..67f5d88532 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' import * as agentCore from '../src/index.ts' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -54,4 +55,25 @@ describe('dsh-agent-core bundle', () => { expect(agentCore.Config).toBeDefined() expect(agentCore.name).toBe('agent-core') }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { + // Postmortem 0001 guard: a stray `export default apply` makes the Loader's + // `unwrapExports` (`exports.default ?? exports`) collapse the module to the + // bare `apply` function, DROPPING the named `name`/`Config`. This package has + // no `inject` export (it mounts children that carry their own), so that + // collapse would NOT crash at load — the plugin would boot but silently lose + // its config schema. This bundle is also never Loader-unwrapped by any smoke + // (the apps import it directly; the mount test namespace-mounts it), so this + // is its ONLY export-shape guard. Assert directly AND through the real + // `unwrapExports` so adding `export default` to src/index.ts fails here. + expect('default' in agentCore).toBe(false) + expect(typeof agentCore.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(agentCore) as Record + expect(unwrapped).toBe(agentCore) + expect(unwrapped.name).toBe('agent-core') + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) }) diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 38894f3146..d48ecaa2b3 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -16,7 +16,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | -Because there is no logger entry in the package, the footgun is **structurally unreachable from the leaf**: a leaf author cannot wire a stdout logger into the ACP config, because the leaf only picks backends, not the front door. +Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.) ## Config diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 227b4c67b9..7a02837fca 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' import * as acpAgent from '../src/index.ts' /** @@ -49,4 +50,24 @@ describe('dsh-acp-agent composition', () => { expect(acpAgent.name).toBe('acp-agent') expect(acpAgent.Config).toBeDefined() }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { + // Postmortem 0001 guard: a stray `export default apply` makes the Loader's + // `unwrapExports` (`exports.default ?? exports`) collapse the module to the + // bare `apply` function, DROPPING the named `name`/`Config`. This package has + // no `inject` export, so that collapse would NOT crash at load (the keyless + // bin smoke would still answer `initialize`) — it would silently lose its + // config schema. So guard the shape directly here: assert no `default` + // export, and that the real `unwrapExports` leaves `name`/`Config`/`apply` + // intact. Adding `export default` to src/index.ts fails this test. + expect('default' in acpAgent).toBe(false) + expect(typeof acpAgent.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(acpAgent) as Record + expect(unwrapped).toBe(acpAgent) + expect(unwrapped.name).toBe('acp-agent') + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) }) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index ab095bda66..f72de0a1da 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' import { AgentId } from '@deepseek-ai/dsh-agent' import * as stdioAgent from '../src/index.ts' @@ -68,4 +69,24 @@ describe('dsh-stdio-agent app', () => { expect(stdioAgent.name).toBe('stdio-agent') expect(stdioAgent.Config).toBeDefined() }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { + // Postmortem 0001 guard: a stray `export default apply` makes the Loader's + // `unwrapExports` (`exports.default ?? exports`) collapse the module to the + // bare `apply` function, DROPPING the named `name`/`Config`. This package has + // no `inject` export, so that collapse would NOT crash at load (the keyless + // echo smoke would still boot the tree) — it would silently lose its config + // schema. So guard the shape directly here: assert no `default` export, and + // that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding + // `export default` to src/index.ts fails this test. + expect('default' in stdioAgent).toBe(false) + expect(typeof stdioAgent.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(stdioAgent) as Record + expect(unwrapped).toBe(stdioAgent) + expect(unwrapped.name).toBe('stdio-agent') + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) }) From bfc5a9097d1489fdac38b1c3107b4a9cc9916bbb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:08:11 +0800 Subject: [PATCH 79/87] fix review findings: soften the last stdout-purity overclaim in the acp-agent module doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp-agent README/RFC overclaim was already softened; this module doc comment still said the footgun is "structurally unreachable from the leaf". A leaf cordis.yml CAN still add a sibling @cordisjs/plugin-logger-console — the app does not prevent it. Reword to the accurate claim: the app gives the default front door no logger entry to misconfigure, and the "never add a stdout logger to an ACP leaf" rule still stands. --- packages/ui/acp-agent/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 0ff3609fbb..625467cac2 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -9,8 +9,10 @@ * a stray console logger would corrupt the protocol frames (the [stdout-purity * footgun]). This package contains NO console-logger entry, NO `hmr` (the editor * owns the subprocess), and pre-creates NO agents (ACP `session/new` creates - * them on demand) — so the footgun is structurally unreachable from the leaf: - * there is no logger entry to get wrong. + * them on demand) — so the default front door has no logger entry to get wrong. + * (A leaf `cordis.yml` could still add a sibling `@cordisjs/plugin-logger-console`, + * which this app does not prevent — so the rule "never add a stdout logger to an + * ACP leaf" still stands; the app just gives the leaf nothing to misconfigure.) * * The leaf supplies only the swappable backends: the LLM adapter (`llm-deepseek` * for the real model, `llm-replay` for keyless snapshot replay) and the bash From 9e86fd995ccc0dd30eb4c7100190d6b1fc3730a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:23:19 +0800 Subject: [PATCH 80/87] fix(ci): repoint the demo smoke test at the dsh-stdio-agent bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI "Demo smoke test" step booted the deleted examples/echo-agent/start.ts (removed when the boot glue moved into the dsh-stdio-agent bin), so CI failed on node 24 + 26 while the local gates and e2e passed. Invoke `pnpm run demo:echo` instead of hardcoding the boot path — that routes through the canonical demo script, so the smoke can never drift from it again. The output assertions and the .sessions/_no-cwd/main-session-*.jsonl artifact check are unchanged (verified the new bin produces identical output). --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 562ffca1bc..d9c5ee0350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - name: Demo smoke test run: | set -euo pipefail - out=$(printf 'echo ci smoke\n' | timeout 60 node --expose-internals --import tsx examples/echo-agent/start.ts 2>&1) + out=$(printf 'echo ci smoke\n' | timeout 60 pnpm run demo:echo 2>&1) echo "$out" echo "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' echo "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' From 356780817193b97460a3f4fcc67ebcd8cf41c172 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:13:57 +0800 Subject: [PATCH 81/87] fix review findings: harden the app bins + built-bin smokes, arch-exception doc, snapshot fixture-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKER — the published lib/bin.js (stdio + acp) was exercised only via tsx (demo:* / the src/bin.ts smokes); the built artifact under plain `node` was unguarded. Root-cause on the BUILT bin: 1. Settle race: boot() returned once loader.create() registered the include ENTRY, but the include loads its child plugins asynchronously — so boot() (and main()) resolved while the app plugins (stdin reader, agent loop, ACP bridge) were still mounting. A CLI with no attached handles yet exits 0 silently, and a load error surfaces as an unhandled rejection AFTER boot. Fix: `await ctx.loader.await()` after create() — settle the whole tree. 2. Config-path robustness: hand the include the config's ABSOLUTE file:// URL so resolution never depends on ctx.baseUrl / can never fall back to cwd. Both bins fixed identically. NOTE: the cordis Loader resolves a config's bare plugin specifiers via its internal module loader, active only under `node --expose-internals`; the bin cannot add a node flag itself, so this is documented in the bin JSDoc + both package READMEs (the demos already comply). The repo `examples/*/cordis.yml` are tsx-only artifacts (workspace plugins resolve through the tsconfig paths map, not node_modules), so they are not a valid plain-node bin target — the smokes use a real-install-shaped temp dir. Fail loud on a load failure: boot() previously exited 0 SILENTLY when a config path's directory does not exist — the include plugin fails to IMPORT, the cordis Loader catches+LOGS it and leaves the entry with no fiber (no rejection), and `loader.await()` does not rethrow (EntryTree.await uses Promise.allSettled). Fix: boot() now calls assertEntriesLoaded(ctx) after the tree settles and throws on any entry with no fiber, so a typo'd config dir exits non-zero with a clear message. main() also installs an unhandledRejection guard (installFailLoud) that replaces Node's stack dump with a single labelled stderr line for the companion case (a missing config FILE in a real dir, whose include-init throw surfaces as a rejection Node already exits non-zero on). Regression tests added to both built-bin smokes (missing dir + missing file → non-zero exit + stderr); verified the missing-dir test fails on the pre-fix bin. Built-bin smokes (the reviewer's ask): packages/ui/{stdio,acp}-agent/tests/ built-bin.e2e.ts run the REAL lib/bin.js under `node` (NOT tsx) in a temp consumer dir, asserting the stdio echo round-trip / the acp initialize response + stdout purity, plus the fail-loud cases above. They build-gate (skip if lib/ absent) and run in a new ci.yml step after the build. Issue 2 — packages/README.md + docs/architecture.md said "plugins depend on interfaces, never on the concrete loop", but dsh-agent-core imports the concrete dsh-agent-loop. Scope the rule to EXTENSION plugins and carve out the sanctioned COMPOSITION/bundle exception (dsh-agent-core composes the concrete spine); note it in the implemented RFC too. Issue 3 — examples/acp-agent/tests/acp.snapshot.ts fixture-guard claimed no-model scenarios need no session.jsonl, but runScenario() always boots llm-replay with the session.jsonl path and loadReplayScript() throws when it is absent. Require session.jsonl for ALL scenarios (no-model ones ship a header-only fixture) and rewrite the comment to match reality. --- .github/workflows/ci.yml | 9 + docs/architecture.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 20 +- knip.json | 4 + packages/README.md | 2 +- packages/ui/acp-agent/README.md | 2 + packages/ui/acp-agent/src/bin.ts | 84 +++++++- packages/ui/acp-agent/tests/built-bin.e2e.ts | 187 ++++++++++++++++++ packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/bin.ts | 83 +++++++- .../ui/stdio-agent/tests/built-bin.e2e.ts | 166 ++++++++++++++++ 12 files changed, 531 insertions(+), 32 deletions(-) create mode 100644 packages/ui/acp-agent/tests/built-bin.e2e.ts create mode 100644 packages/ui/stdio-agent/tests/built-bin.e2e.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9c5ee0350..88979aab69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,12 @@ jobs: # per-run session log named main-session-.jsonl. Assert one exists. ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null rm -rf .sessions + + # The published `bin` is `lib/bin.js`, run under plain `node` by a real + # consumer — NOT the tsx dev path the demo smoke and demo:* scripts use. + # These keyless smokes boot the BUILT bins (this step runs AFTER the build) + # in a temp dir that mirrors a real install, catching a regression in the + # published artifact that tsx would mask. They self-skip if lib/ is absent, + # so the e2e job (which does not build) does not run them. + - name: Built-bin smoke test (published lib/bin.js under node) + run: pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts diff --git a/docs/architecture.md b/docs/architecture.md index 891e851337..b5beec1143 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ For a catalog of the **data structures** this architecture moves around — the └─────────────────────────────────────────────────────────────┘ ``` -Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. +Dependency rule: **extension** plugins depend on interface packages, never on `dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The one sanctioned exception is a **composition/bundle** package whose job IS to assemble the concrete spine: `dsh-agent-core` bundles `dsh-agent-loop` (and the other concrete spine plugins) by design, so it depends on the concrete loop on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means publishing a different bundle, not rewiring every extension. ## Service map 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 6ab2c6a15f..b6c30819dd 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 @@ -12,7 +12,7 @@ The deeper problem was a **coupled front-door cluster** that lived at the leaf w Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@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. +- **`@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. - **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). diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 039dbace8c..be6aabada0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -134,19 +134,21 @@ describe('snapshot fixtures', () => { }) it('every registered scenario has its required fixture files', async () => { - // Required files are per-KIND. Every scenario has an input script and an - // stdout golden. Only model scenarios persist a session log, so only they - // require `session.jsonl` (the replay source AND expected-log artifact); - // a no-model scenario boots `llm-replay` with an empty script and needs no - // session fixture. Authored scenarios additionally ship the - // `replay.override.json` sidecar that drives their model behavior. + // Every scenario has an input script and an stdout golden. EVERY scenario + // also needs `session.jsonl`: the harness boots `llm-replay` with that path + // as the replay source for ALL scenarios (acp.snapshot.ts passes + // `fixtureFile: /session.jsonl` unconditionally), and `loadReplayScript` + // throws "fixture not found" when it is absent and no override replaces it. + // A no-model scenario ships a header-only `session.jsonl` (it derives to an + // empty script — no model call is made); a model scenario's fixture also + // doubles as the expected-log artifact the run is diffed against. An authored + // (non-`recorded`) model scenario additionally ships a `replay.override.json` + // sidecar for the throw/hang cases a derived script cannot express. for (const { name, hasModelTurn, recorded } of SCENARIOS) { const dir = join(SNAPSHOTS_DIR, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) - if (hasModelTurn) { - expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) - } + expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) if (hasModelTurn && !recorded) { expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true) } diff --git a/knip.json b/knip.json index e73d165138..b3ce7c1d4b 100644 --- a/knip.json +++ b/knip.json @@ -32,6 +32,10 @@ "packages/ui/acp-agent": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/ui/stdio-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/packages/README.md b/packages/README.md index d5e1f55fe6..037bb9c66c 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,7 +40,7 @@ dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-json dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` -The rule: 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. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). +The rule: **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)). ## What goes where diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index d48ecaa2b3..3403ef2126 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -36,4 +36,6 @@ The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the - honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); - in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit. +Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers through its internal module loader, active only under that flag. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.) + All diagnostics go to **stderr** — stdout is the protocol. diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 00a8bfdec0..1352cde0ed 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -59,10 +59,70 @@ function loadEnv(): void { } /** - * Boot the Loader against `absoluteConfigPath`. `baseUrl` is pinned to the - * config's directory and the include gets only the basename, so the config's - * relative plugin/include paths resolve as the upstream `cordis` bin does. - * Returns the root context. + * 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) + }) +} + +/** + * 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. + */ +function assertEntriesLoaded(ctx: Context): void { + const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined) + 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 { const ctx = new Context() @@ -70,19 +130,23 @@ export async function boot(absoluteConfigPath: string): Promise { await ctx.plugin(Loader) await ctx.loader.create({ name: '@cordisjs/plugin-include', - config: { path: `./${basename(absoluteConfigPath)}` }, + config: { path: pathToFileURL(absoluteConfigPath).href }, }) + await ctx.loader.await() + assertEntriesLoaded(ctx) return ctx } /** - * Entry point. 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. + * 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 { + installFailLoud() const snapshotMode = process.env.DSH_SNAPSHOT const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode) if (snapshotMode !== 'replay') loadEnv() diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..3793232bda --- /dev/null +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' +import { Readable, Writable } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' + +/** + * BUILT-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` + * boots `src/bin.ts` under tsx — but the package's `bin` field points at + * `lib/bin.js`, run under plain `node` by a real consumer. This runs the REAL + * `lib/bin.js` under `node` (NOT tsx) and asserts it answers an `initialize` + * JSON-RPC frame, so a regression in the published entry (a settle race that + * exits before the bridge attaches, a stdout logger leaking onto the protocol) + * fails here. + * + * It build-gates: SKIPS if `lib/bin.js` is absent (suite run without + * `pnpm run build`); CI runs it after the build step. Setup mirrors a real + * install (a temp dir whose `node_modules` symlinks the built packages) and runs + * `node --expose-internals` (the cordis Loader resolves bare plugin specifiers + * via its internal module loader, active only under that flag). KEYLESS: + * `initialize` never reaches the model; a dummy key lets `llm-deepseek` boot. + */ + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +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', + 'session-persistence/session-persistence', + 'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent', +] +const vendorPackages = [ + 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', + 'schemastery', 'cosmokit', +] +// Third-party deps the ACP bridge needs (resolved from the acp package's own +// node_modules and linked into the consumer so plain node finds them). +const npmDeps = ['@agentclientprotocol/sdk', 'zod'] + +async function pkgName(absDir: string): Promise { + const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } + return json.name +} + +async function link(target: string, name: string, nm: string): Promise { + const dest = join(nm, name) + await mkdir(dirname(dest), { recursive: true }) + await symlink(target, dest) +} + +/** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */ +async function makeConsumer(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-')) + const nm = join(dir, 'node_modules') + for (const rel of dshPackages) { + const abs = join(repoRoot, 'packages', rel) + await link(abs, await pkgName(abs), nm) + } + for (const v of vendorPackages) { + const abs = join(repoRoot, 'vendor', v) + await link(abs, await pkgName(abs), nm) + } + for (const dep of npmDeps) { + const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`)) + await link(dirname(resolved), dep, nm) + } + await writeFile(join(dir, 'cordis.yml'), [ + '- id: llm-deepseek', + ' name: \'@deepseek-ai/dsh-llm-deepseek\'', + ' config:', + ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + ' models: [deepseek-v4-flash]', + '- id: bash', + ' name: \'@deepseek-ai/dsh-bash-local\'', + '- id: acp-agent', + ' name: \'@deepseek-ai/dsh-acp-agent\'', + ' config:', + ' model: deepseek-v4-flash', + ' systemPrompt: \'test agent\'', + '', + ].join('\n')) + return dir +} + +let consumer: string | undefined +let child: ReturnType | undefined + +afterEach(async () => { + if (child !== undefined) { child.kill('SIGKILL'); child = undefined } + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + consumer = undefined +}) + +describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, no tsx)', () => { + it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => { + consumer = await makeConsumer() + child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], { + cwd: consumer, + // Dummy key: initialize never reaches the model, so it is never used. + env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + const stderr: string[] = [] + child.stderr!.setEncoding('utf8') + child.stderr!.on('data', (c: string) => stderr.push(c)) + // Tee raw stdout for a protocol-purity check, and feed it to the SDK client. + const rawOut: string[] = [] + const passthrough = new Readable({ read() {} }) + child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) }) + child.stdout!.on('end', () => passthrough.push(null)) + const stream = ndJsonStream( + Writable.toWeb(child.stdin!) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const makeClient = (_a: AcpAgent): Client => ({ + sessionUpdate(_p: SessionNotification): Promise { return Promise.resolve() }, + requestPermission(_p: RequestPermissionRequest): Promise { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + const client = new ClientSideConnection(makeClient, stream) + + const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // A response at all proves the built bin booted the bridge (the settle-race + // regression would exit before answering); loadSession proves the real app + // mounted, not a collapsed export shape. + expect(init.agentCapabilities?.loadSession).toBe(true) + expect(stderr.join('')).not.toContain('without inject') + // stdout purity: every emitted line is a JSON-RPC frame, no logger leak. + for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) { + expect(() => JSON.parse(line) as unknown).not.toThrow() + } + }, 30_000) + + it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { + // A typo'd config path must fail clearly, not exit 0. The include plugin + // itself cannot be imported from a non-existent dir; the Loader logs that and + // leaves the entry with no fiber, which boot()'s entry-load check throws on. + const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml') + expect(code).not.toBe(0) + expect(stderr).toContain('failed to load') + }, 30_000) + + it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { + // The directory exists (the include imports), but the file does not — the + // include's init throws "config file not found", which surfaces as an + // unhandled rejection the fail-loud guard turns into a non-zero exit. + consumer = await makeConsumer() + const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer) + expect(code).not.toBe(0) + expect(stderr).toContain('config file not found') + }, 30_000) +}) + +/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ +function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], { + cwd, + env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stderr = '' + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (c: string) => { stderr += c }) + const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000) + proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) }) + proc.on('error', (err) => { clearTimeout(timer); reject(err) }) + proc.stdin.end() + }) +} diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 286fe6a85b..a78df0fa72 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -31,7 +31,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte ## The bin -`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config — the boot glue the `examples/*/start.ts` files once each duplicated. The `demo:echo` / `demo:coding` scripts invoke it. +`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way. ## Example leaf `cordis.yml` diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index 015a462f52..dae0299f06 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -13,7 +13,7 @@ */ import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -38,10 +38,72 @@ function loadEnv(): void { } /** - * Boot the Loader against `configPath` (resolved from the CWD). `baseUrl` is - * pinned to the config's directory and the include is handed only the basename, - * so the config's relative plugin/include paths resolve exactly as the upstream - * `cordis` bin does. Returns the root context (the process owns its lifetime). + * 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. + */ +function assertEntriesLoaded(ctx: Context): void { + const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined) + 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:coding` 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 { const absolute = resolve(process.cwd(), configPath) @@ -50,17 +112,20 @@ export async function boot(configPath: string): Promise { await ctx.plugin(Loader) await ctx.loader.create({ name: '@cordisjs/plugin-include', - config: { path: `./${basename(absolute)}` }, + config: { path: pathToFileURL(absolute).href }, }) + await ctx.loader.await() + assertEntriesLoaded(ctx) return ctx } /** - * Entry point: 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). + * 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 { + installFailLoud() loadEnv() await boot(argv[0] ?? './cordis.yml') } diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..cd6bfd6475 --- /dev/null +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -0,0 +1,166 @@ +import { spawn } from 'node:child_process' +import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +/** + * BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes + * boot `src/bin.ts` under tsx — but the package's `bin` field points at + * `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure + * modes the built bin had: (1) `boot()` returned before the loader tree settled, + * so the process exited 0 with no output and load errors surfaced as unhandled + * rejections AFTER boot; (2) config-path resolution could fall back to the cwd. + * This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the + * banner + echo round-trip, so a regression in the published entry fails here. + * + * It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`) + * the test SKIPS with a note. CI runs it after the build step. Setup mirrors a + * real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored + * `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml` + * that loads the app + the example's mock backend, and `node --expose-internals` + * (the cordis Loader resolves bare plugin specifiers via its internal module + * loader, active only under that flag — the same flag `demo:echo` passes). + */ + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') + +// Workspace packages the stdio app's tree needs, by repo-relative path. Each is +// symlinked into the temp consumer's node_modules under its package name, so +// plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml +// to the built `lib/` (package.json `main`), exactly as an installed dep would. +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', + 'session-persistence/session-persistence', + 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', +] +const vendorPackages = [ + 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', + 'schemastery', 'cosmokit', +] + +async function pkgName(absDir: string): Promise { + const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } + return json.name +} + +/** + * Build a temp consumer dir: `node_modules` with the workspace + vendor packages + * symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml` + * that wires them onto the stdio app. Returns the dir (caller removes it). + */ +async function makeConsumer(welcome: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) + const nm = join(dir, 'node_modules') + for (const rel of dshPackages) { + const abs = join(repoRoot, 'packages', rel) + const name = await pkgName(abs) + const target = join(nm, name) + await mkdir(dirname(target), { recursive: true }) + await symlink(abs, target) + } + for (const v of vendorPackages) { + const abs = join(repoRoot, 'vendor', v) + const name = await pkgName(abs) + const target = join(nm, name) + await mkdir(dirname(target), { recursive: true }) + await symlink(abs, target) + } + // The example's mock model + echo tool are example-local TS plugins (Node 24+ + // strips types natively, so plain `node` loads them); they import the workspace + // packages the symlinked node_modules now provides. + await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true }) + await writeFile(join(dir, 'cordis.yml'), [ + '- id: mock-llm', + ' name: \'./src/mock-llm.ts\'', + '- id: echo-tool', + ' name: \'./src/echo-tool.ts\'', + '- id: bash', + ' name: \'@deepseek-ai/dsh-bash-local\'', + '- id: stdio-agent', + ' name: \'@deepseek-ai/dsh-stdio-agent\'', + ' config:', + ' model: mock-echo', + ' systemPrompt: \'demo\'', + ` welcome: '${welcome}'`, + '', + ].join('\n')) + return dir +} + +/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */ +function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> { + return new Promise((resolve, reject) => { + // --expose-internals: the cordis Loader resolves bare plugin specifiers via + // its internal module loader (active only under this flag); demo:echo passes + // it too. NO tsx — this is the published `node lib/bin.js` path. + const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], { + cwd, + // Mock model: never calls the network, so no key needed. + env: { ...process.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (c: string) => { stdout += c }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (c: string) => { stderr += c }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 25_000) + child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + child.on('error', (err) => { clearTimeout(timer); reject(err) }) + child.stdin.write(`${line}\n`) + child.stdin.end() + }) +} + +let consumer: string | undefined + +afterEach(async () => { + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + consumer = undefined +}) + +describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.js, no tsx)', () => { + it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => { + consumer = await makeConsumer('BUILT-BIN-OK ready.') + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') + expect(stderr).not.toContain('UNHANDLED') + expect(stderr).not.toContain('without inject') + // The banner proves boot() awaited the tree (the settle-race regression would + // exit 0 with empty stdout); the round-trip proves the whole app mounted. + expect(stdout).toContain('BUILT-BIN-OK ready.') + expect(stdout).toContain('[tool call] echo') + expect(stdout).toContain('[tool result] ECHO: HI') + expect(code).toBe(0) + }, 30_000) + + it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { + // A consumer who typos the config path must get a clear failure, not silent + // success. This dir does not exist, so the include PLUGIN itself fails to + // import; the cordis Loader logs that and leaves the entry with no fiber (no + // rejection), which `boot()`'s entry-load check turns into a thrown error. + consumer = await makeConsumer('unused') + const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') + expect(code).not.toBe(0) + expect(stderr).toContain('failed to load') + }, 30_000) + + it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { + // The config DIRECTORY exists (the include plugin imports), but the file does + // not — the include's init throws "config file not found", which surfaces as + // an unhandled rejection the fail-loud guard turns into a non-zero exit. + consumer = await makeConsumer('unused') + const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '') + expect(code).not.toBe(0) + expect(stderr).toContain('config file not found') + }, 30_000) +}) From e04ca553745de3583d4cbb539d0617be80205f84 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:46:38 +0800 Subject: [PATCH 82/87] fix review findings: require session.jsonl for every snapshot scenario The per-kind fixture guard claimed no-model scenarios need no session.jsonl, but the replay path requires one for ALL scenarios: runScenario() passes `fixtureFile: /session.jsonl` unconditionally and llm-replay's loadReplayScript() throws "fixture not found" when it is absent and no override replaces it. A no-model scenario ships a header-only session.jsonl that derives to an empty script. The guard + its comment now match that reality, so a future no-model scenario following them won't fail at subprocess startup. --- examples/acp-agent/tests/acp.snapshot.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 039dbace8c..be6aabada0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -134,19 +134,21 @@ describe('snapshot fixtures', () => { }) it('every registered scenario has its required fixture files', async () => { - // Required files are per-KIND. Every scenario has an input script and an - // stdout golden. Only model scenarios persist a session log, so only they - // require `session.jsonl` (the replay source AND expected-log artifact); - // a no-model scenario boots `llm-replay` with an empty script and needs no - // session fixture. Authored scenarios additionally ship the - // `replay.override.json` sidecar that drives their model behavior. + // Every scenario has an input script and an stdout golden. EVERY scenario + // also needs `session.jsonl`: the harness boots `llm-replay` with that path + // as the replay source for ALL scenarios (acp.snapshot.ts passes + // `fixtureFile: /session.jsonl` unconditionally), and `loadReplayScript` + // throws "fixture not found" when it is absent and no override replaces it. + // A no-model scenario ships a header-only `session.jsonl` (it derives to an + // empty script — no model call is made); a model scenario's fixture also + // doubles as the expected-log artifact the run is diffed against. An authored + // (non-`recorded`) model scenario additionally ships a `replay.override.json` + // sidecar for the throw/hang cases a derived script cannot express. for (const { name, hasModelTurn, recorded } of SCENARIOS) { const dir = join(SNAPSHOTS_DIR, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) - if (hasModelTurn) { - expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) - } + expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) if (hasModelTurn && !recorded) { expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true) } From a96b1e3ae76783df00578e81e1161488b9901886 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:17:00 +0800 Subject: [PATCH 83/87] docs(agents): capture stacked-PR orchestration + sharper real-entry-path lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hard-won lessons from running a 7-PR simplification stack through two waves of review feedback: - New "## Orchestrating review feedback across a stacked PR chain" section: one worktree per branch; a fix belongs on the PR that introduced the issue then flows DOWN; review-fixes are separate commits never amends; delegated work is trust-but-verify (prove a regression guard FAILS on unfixed code); reply in-thread on the merits. - "## Conventions" gains a "Never rewrite a pushed branch" rule next to the merge-commit rule: update a child by merging the parent down, never rebase/amend/force-push a pushed branch; a fix lands on its originating PR. - Extended the "Line coverage is not behavior coverage" defensive-patterns bullet with two corollaries this stack re-taught: (1) a real-load-path test only GUARDS the export shape if a broken shape actually FAILS it — an inject-less composition plugin boots fine on a stray `export default`, so it needs an explicit no-default + unwrapExports assertion; (2) "real entry path" means the PUBLISHED artifact (built lib/bin.js under plain node), not the dev runtime (tsx), which masks boot settle-races, module-resolution differences, and a load failure that loader.await()'s Promise.allSettled swallows. --- AGENTS.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b2bfcc27a3..2c4de219be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,16 @@ When carrying out the change fights back — a removal forces an awkward migrati The worked example is [Keep one public stop primitive](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md): it proposed removing BOTH `Agent.abort()` and `Agent.whenIdle()` as redundant stop/quiescence surface. Validating against the code, `abort()` was genuinely dead — no production caller, the loop aborts its own `AbortController` directly — so it was removed as proposed. But `whenIdle()` was load-bearing: a deliberate quiescence primitive with live ACP consumers, and the RFC's suggested migration (observe the `running`→`idle` transition by hand) is exactly the brittle path § Defensive patterns warns against ("Async state is not synchronous state"). So only `abort()` shipped, `whenIdle()` stayed, and the RFC's text was amended on the way to `implemented/` to record the narrowed scope — the landed RFC is not a lie about what was built. +## Orchestrating review feedback across a stacked PR chain + +A wave of review comments lands across several PRs in a dependent stack (`A ← B ← C …`) at once. Resolving it well is a discipline of its own, learned the hard way: + +- **One worktree per PR branch; never rewrite a pushed branch.** Each PR's fixes happen in that PR's own worktree. To bring a child up to date with a parent's new commits, **merge the parent down** — never rebase/amend/force-push a branch that is already pushed (see [§ Conventions](#conventions) "Never rewrite a pushed branch"). The stacked-merge graph and the per-round review-fix history depend on it. +- **A fix belongs on the PR that INTRODUCED the issue, then flows DOWN.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` already carries the same file through the chain. Originating the fix on the downstream `C` leaves `B` shipping the unfixed code and the fix invisible to a reviewer of `B`. (This bit us: a snapshot-test guard flagged on the lower PR got fixed only on the top PR, so the lower PR still read as unaddressed until the fix was relocated to its true origin and merged down.) +- **Each review fix is a SEPARATE commit, never an amend.** The "fix review findings" commit is part of the record — it shows what the review caught and how. Amending erases that. (Amend is fine only for your own not-yet-pushed work.) +- **Delegated work is trust-but-verify.** When sub-agents implement fixes in parallel, their report describes what they INTENDED, not necessarily what landed. Re-run the gates yourself on the actual tree, and for a regression guard, **prove it FAILS on the unfixed code** (introduce the regression, watch the test go red, revert) — a guard that passes both ways guards nothing. A sub-agent that "reframes the problem as already-handled" instead of fixing it is a signal to dig in personally, not to accept the reframing. +- **Triage on the merits, then reply in-thread.** Verify each comment against the code before acting (a reviewer flagging the right symptom can still mis-diagnose the cause — confirm both). Reply in the GitHub review thread (`gh api …/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the commit that carries it. + ## Architecture This codebase is based on the **Cordis** framework, built microkernel-style: **everything is a plugin**. All necessary Cordis dependencies are copied into this monorepo as vendored source (under `vendor/`) instead of being depended on via npm. @@ -189,6 +199,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **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. - **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away. +- **Never rewrite a pushed branch in a stacked chain.** Once a branch is pushed (and especially once it has a PR), do NOT `rebase`, `amend`, or force-push it. Update a child branch by **merging its parent down** (`git merge ` into the child, as a new merge commit), never by rebasing the child onto the parent's new tip. Rewriting a shared branch diverges it from what the parent and GitHub recorded, which breaks the stacked-merge graph and erases the review-fix history that documents what each round caught. Amending is fine ONLY for your own not-yet-pushed, not-yet-reviewed work. A corollary on WHERE a fix lands: a review fix belongs on the PR that **introduced** the issue, even when a downstream PR in the stack also carries the affected file — fix it on the originating branch, then merge that branch DOWN the chain, rather than originating the fix on the downstream PR (where it would be invisible to a reviewer of the PR that actually owns the code). - **TODO markers**: use `FIXME`/`TODO`/`XXX` to flag known issues by urgency — see [docs/development.md](docs/development.md) for the semantics of each. - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. @@ -205,7 +216,9 @@ Each bullet is a bug class that bit us; the rule prevents the reoccurrence. - **Contain callback exceptions at the boundary.** A user-supplied listener (`onTaskDone`, event handlers) that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; never let one bad subscriber break core lifecycle. - **Never hand untrusted/model output the ambient environment or predictable paths.** Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/ `*TOKEN*`) so the harness's own credentials can't leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only (`'wx'`, `0o600`) opens — predictable world-readable paths invite symlink races and disclosure. - **e2e tests own their resources.** Real-API/integration tests must create the harness in the test and dispose it in `afterEach` (even on failure/retry/timeout), so a flaky run doesn't leak processes or contexts. Shared fixtures live in a plain `tests/harness.ts` module, NOT another `*.e2e.ts` file — importing a spec file re-registers its `describe` and duplicates real API calls. Verify the WORLD, not the agent's self-report: re-run the command/check externally and assert files are byte-identical where they should be unchanged (a keyword probe lets a cheating agent pass). -- **Line coverage is not behavior coverage; test the REAL entry path, not a synthetic stand-in.** 100% per-file coverage and a green suite are necessary, not sufficient — they prove lines ran, not that the feature works the way it ships. A plugin shipped via `cordis.yml` is loaded by the cordis Loader, which calls `Loader.unwrapExports` (`exports.default ?? exports`) and then constructs a fiber from the module's `inject`/`name`/`Config` namespace exports. A test that mounts the plugin by hand-building `ctx.plugin({ name, inject, apply })` (or even `ctx.plugin(NamespaceImport)`) BYPASSES `unwrapExports` entirely, so it cannot catch a broken export shape. This bit us hard: a stray `export default apply` made `unwrapExports` collapse the module to the bare function, dropping `inject` — so every service read threw `cannot get property … without inject` the instant a real editor connected, while 178 hand-mounted tests stayed green. The guard is at least one test that drives the plugin through its REAL load path (a subprocess booting the example via the Loader, or the Loader API directly), exercising the headline operations end-to-end. It runs WITHOUT a key when the operation doesn't call the model (`session/new`/`session/load` reach the factory but never the LLM), so there is no excuse to skip it. Corollary: when an `*.e2e.ts` spawns the example from a temp cwd, set `TSX_TSCONFIG_PATH` to the repo-root tsconfig — the unbuilt `paths` map is found by searching UP from cwd, so a temp cwd outside the repo silently falls back to built `lib/`, which both hides source changes and only "works" when a stale build happens to exist. +- **Line coverage is not behavior coverage; test the REAL entry path, not a synthetic stand-in.** 100% per-file coverage and a green suite are necessary, not sufficient — they prove lines ran, not that the feature works the way it ships. A plugin shipped via `cordis.yml` is loaded by the cordis Loader, which calls `Loader.unwrapExports` (`exports.default ?? exports`) and then constructs a fiber from the module's `inject`/`name`/`Config` namespace exports. A test that mounts the plugin by hand-building `ctx.plugin({ name, inject, apply })` (or even `ctx.plugin(NamespaceImport)`) BYPASSES `unwrapExports` entirely, so it cannot catch a broken export shape. This bit us hard: a stray `export default apply` made `unwrapExports` collapse the module to the bare function, dropping `inject` — so every service read threw `cannot get property … without inject` the instant a real editor connected, while 178 hand-mounted tests stayed green. The guard is at least one test that drives the plugin through its REAL load path (a subprocess booting the example via the Loader, or the Loader API directly), exercising the headline operations end-to-end. It runs WITHOUT a key when the operation doesn't call the model (`session/new`/`session/load` reach the factory but never the LLM), so there is no excuse to skip it. Corollary: when an `*.e2e.ts` spawns the example from a temp cwd, set `TSX_TSCONFIG_PATH` to the repo-root tsconfig — the unbuilt `paths` map is found by searching UP from cwd, so a temp cwd outside the repo silently falls back to built `lib/`, which both hides source changes and only "works" when a stale build happens to exist. Two sharper corollaries this bit us with again: + - **A real-load-path test only GUARDS the export shape if a broken shape actually FAILS it.** The original crash (`cannot get property … without inject`) fired because that plugin HAS `inject`. A plugin with NO `inject` (a composition/bundle plugin that mounts children carrying their own inject, e.g. `dsh-agent-core` and the app packages) does NOT crash on a stray `export default` — `unwrapExports` silently drops `Config`/`name` and the plugin boots anyway — so a Loader smoke stays green while the export shape is broken. For such plugins add an EXPLICIT assertion that the regression fails: `expect('default' in mod).toBe(false)` plus running the module through the real `Loader.prototype.unwrapExports` and asserting `name`/`Config`/`apply` survive. Prove it: add `export default apply`, watch the test go red, revert. + - **"Real entry path" means the PUBLISHED ARTIFACT, not the dev runtime.** A test (or a `demo:*` smoke) that boots `src/bin.ts` under `tsx` is NOT the same code a consumer runs — the package `bin` field points at the built `lib/bin.js` under plain `node`. tsx masks failure modes the published artifact has: a boot settle-race that exits 0 before the app's handles attach, module-resolution differences (the unbuilt `paths` map vs node_modules), and a load failure that `loader.await()`'s `Promise.allSettled` SWALLOWS so a typo'd config silently exits 0. The guard is a smoke that runs the built `lib/bin.js` under plain `node` in a node_modules-shaped temp dir (symlinked workspace + vendor packages), asserts the real output, AND asserts a genuinely-missing config exits NON-ZERO. The tsx demo is necessary but not sufficient; the published-bin smoke is what catches "green under tsx, broken on install". - **Tag spelling and EOF hygiene.** cordis.yml interpolates env via the `!!js` tag (js-yaml resolves custom tags under `tag:yaml.org,2002:js`), not `!js` — keep code, comments, and docs consistent. Files end with exactly one trailing newline; `git diff --check` (a pre-push gate) rejects new blank lines at EOF. ## Type Safety and Documentation From b7d018580e170bdafbe139dbd54316738161e64e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:35:46 +0800 Subject: [PATCH 84/87] fix review findings: skip lib/ build-output refs in verify-package-paths; resolve acp built-bin npm deps from the declaring package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-package-paths flagged the new built-bin smokes' `lib/bin.js` citations as stale-source drift, failing CI: doc-sync runs BEFORE build, so the build output is absent at lint time. The gate targets moved SOURCE paths, so skip any reference whose target goes through a `lib/` segment — mirroring how the file scan already excludes `lib/`. The acp built-bin smoke resolved `zod`/`@agentclientprotocol/sdk` via `import.meta.resolve` from the test file's own context, but `acp-agent` does not declare them — `dsh-acp` does. Under pnpm's strict layout they are not exposed where the test resolves, so the new CI built-bin step failed with "Cannot find package 'zod'". Resolve each from the `ui/acp` package URL (the one that declares it) instead. --- packages/ui/acp-agent/tests/built-bin.e2e.ts | 16 ++++++++++++---- scripts/verify-package-paths.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index 3793232bda..51c5d53c0b 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promis import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { ClientSideConnection, ndJsonStream, @@ -48,9 +48,14 @@ const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', 'schemastery', 'cosmokit', ] -// Third-party deps the ACP bridge needs (resolved from the acp package's own -// node_modules and linked into the consumer so plain node finds them). +// Third-party deps the ACP bridge needs at runtime. They are declared by +// `dsh-acp` (NOT by `acp-agent`), so they live under `packages/ui/acp/node_modules` +// and are NOT necessarily hoisted where THIS test file can resolve them — pnpm's +// strict layout only exposes a package's deps under that package. Resolve each +// from the `ui/acp` package directory (the one that declares it) so the lookup +// works regardless of hoisting, then symlink it into the consumer for plain node. const npmDeps = ['@agentclientprotocol/sdk', 'zod'] +const acpPkgDir = join(repoRoot, 'packages/ui/acp') async function pkgName(absDir: string): Promise { const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } @@ -76,7 +81,10 @@ async function makeConsumer(): Promise { await link(abs, await pkgName(abs), nm) } for (const dep of npmDeps) { - const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`)) + // Resolve from `ui/acp`'s package.json URL (the package that declares the + // dep), not this test file's location — `acp-agent` does not depend on these. + const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href + const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp)) await link(dirname(resolved), dep, nm) } await writeFile(join(dir, 'cordis.yml'), [ diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 368a607a1d..9f4a4aaa8e 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -28,6 +28,10 @@ * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown * across README/docs/packages/AGENTS, and `.ts` under packages/** and * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source). + * A reference whose target path goes through a `lib/` segment is also skipped: + * that is a build OUTPUT (`packages/ui/acp-agent/lib/bin.js`), emitted only by + * `pnpm run build`, which CI runs AFTER this gate — flagging it would be a false + * positive on a path that is correct but not yet on disk. * * Run: `tsx scripts/verify-package-paths.ts`. */ @@ -111,6 +115,13 @@ function findViolations(absPath: string): Violation[] { // class may have swallowed (`packages/core/tools.` / `…/tools/`). const ref = m[0].replace(/[./]+$/, '') if (existsSync(resolve(root, ref))) continue + // A reference INTO a package's built `lib/` is a build-output path, not an + // authored-source location: it does not exist until `pnpm run build` emits + // it, and CI runs this gate BEFORE the build step. This gate reports stale + // SOURCE paths (a moved package), so skip `lib/` targets the same way the + // file scan excludes `lib/` files — a `packages/ui/acp-agent/lib/bin.js` + // citation in a built-bin smoke is correct, just not yet on disk at lint. + if (ref.split('/').includes('lib')) continue // Only a stale path to a REAL (moved) package is a violation; a segment // matching a live package name is the drift signal. const segments = ref.split('/').slice(1) From d0e1b02a7f5dc2a3bf3913df2a5bfeefecc0899a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:36:04 +0800 Subject: [PATCH 85/87] fix review findings: correct whenIdle live-consumer claim in the stop-surface RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retained-whenIdle paragraph claimed "live consumers (the ACP bridge's settle points)", but `packages/ui/acp/src` has no whenIdle() call — the bridge owns its agents and tears them down via AgentHandle.dispose(). whenIdle()'s live consumers are ACP and agent TESTS awaiting settlement through the public seam. State that. --- .../simplification/2026-06-20-public-agent-stop-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index a737acbbd0..67fe9b07fc 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -16,7 +16,7 @@ The extra surface area made the loop carry a public verb that is mostly a teardo Keep `cancel()` as the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract. -`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent, and it has live consumers (the ACP bridge's settle points). +`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public seam (`packages/ui/acp/tests`, `packages/core/agent-loop/tests`); the production ACP bridge owns its agents and tears them down through `AgentHandle.dispose()`, so `packages/ui/acp/src` itself has no `whenIdle()` call. Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop. From 2903f965485b47c8dabae8a7757ec85cb2b90223 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:36:13 +0800 Subject: [PATCH 86/87] fix review findings: RFC says session.jsonl is required for every snapshot scenario The required-fixture-guard description still said session.jsonl was needed only for model scenarios, but the harness passes /session.jsonl to llm-replay unconditionally, so loadReplayScript() fails for a no-model scenario without it. The code already requires it for all scenarios; align the RFC prose. --- docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2f0fc38bf9..1bc34c8b4d 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 @@ -70,7 +70,7 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack ### Two subcommands, replay in the default gate -`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` for all; `session.jsonl` for model scenarios; `replay.override.json` additionally for authored ones). +`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` + `session.jsonl` for ALL scenarios — the harness passes `/session.jsonl` to `llm-replay` unconditionally, so even a no-model scenario needs its header-only fixture or `loadReplayScript()` fails; `replay.override.json` additionally for authored model scenarios). ## Consequences From 4d7726ecd360cc40f925d43607d428f5fc20a6c6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:47:22 +0800 Subject: [PATCH 87/87] fix review findings: don't treat disabled entries as load failures; scope the verify-package-paths lib skip to a real package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertEntriesLoaded() flagged ANY fiber-less entry as a failed import, but a `disabled: true` entry settles without a fiber by design (Entry.refresh() skips init() when disabled) — a valid "plugin off" config, not a broken import. Both app bins now filter `fiber === undefined && !entry.disabled`. The stdio built-bin smoke gains a disabled-(unresolvable)-entry config that must still boot. The verify-package-paths lib-skip was unconditional and ran before the moved-package check, so a stale group-less `packages/acp-agent/lib/bin.js` (the exact drift this gate catches) was silently ignored just for containing `lib`. Scope the skip: only exempt `lib` when it is the segment after an EXISTING `packages//` root, so a real-but-unbuilt `lib/bin.js` is still exempt while a stale package path flags. --- packages/ui/acp-agent/src/bin.ts | 6 ++++- packages/ui/stdio-agent/src/bin.ts | 7 ++++- .../ui/stdio-agent/tests/built-bin.e2e.ts | 22 ++++++++++++++- scripts/verify-package-paths.ts | 27 ++++++++++++------- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 1352cde0ed..24e31f3251 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -85,9 +85,13 @@ export function installFailLoud(): void { * `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) + 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)`) diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index dae0299f06..92cd9f5e90 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -66,9 +66,14 @@ export function installFailLoud(): void { * 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) + 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)`) diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index cd6bfd6475..7605b35bb7 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -53,8 +53,13 @@ async function pkgName(absDir: string): Promise { * Build a temp consumer dir: `node_modules` with the workspace + vendor packages * symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml` * that wires them onto the stdio app. Returns the dir (caller removes it). + * + * `disabledBrokenEntry` appends an entry that points at a non-existent plugin but + * is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by + * design, so it exercises that the fail-loud entry-load guard does NOT mistake a + * valid disabled entry for a failed import. */ -async function makeConsumer(welcome: string): Promise { +async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise { const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) const nm = join(dir, 'node_modules') for (const rel of dshPackages) { @@ -88,6 +93,9 @@ async function makeConsumer(welcome: string): Promise { ' model: mock-echo', ' systemPrompt: \'demo\'', ` welcome: '${welcome}'`, + ...disabledBrokenEntry + ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] + : [], '', ].join('\n')) return dir @@ -143,6 +151,18 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. expect(code).toBe(0) }, 30_000) + it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { + // A `disabled: true` entry settles without a fiber by design; the fail-loud + // entry-load guard must NOT mistake it for a failed import. Even though its + // plugin path does not exist, the app boots and the round-trip works. + consumer = await makeConsumer('DISABLED-OK ready.', true) + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') + expect(stderr).not.toContain('failed to load') + expect(stdout).toContain('DISABLED-OK ready.') + expect(stdout).toContain('[tool result] ECHO: HI') + expect(code).toBe(0) + }, 30_000) + it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { // A consumer who typos the config path must get a clear failure, not silent // success. This dir does not exist, so the include PLUGIN itself fails to diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 9f4a4aaa8e..7bec754dba 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -28,10 +28,13 @@ * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown * across README/docs/packages/AGENTS, and `.ts` under packages/** and * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source). - * A reference whose target path goes through a `lib/` segment is also skipped: - * that is a build OUTPUT (`packages/ui/acp-agent/lib/bin.js`), emitted only by - * `pnpm run build`, which CI runs AFTER this gate — flagging it would be a false - * positive on a path that is correct but not yet on disk. + * A reference to a package's build OUTPUT (`packages///lib/…`, + * e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also + * skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this + * gate, so flagging it would be a false positive on a path that is correct but + * not yet on disk. That skip is scoped to a REAL package root: a stale + * group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not + * exist — exactly the moved-package drift this gate catches). * * Run: `tsx scripts/verify-package-paths.ts`. */ @@ -115,13 +118,17 @@ function findViolations(absPath: string): Violation[] { // class may have swallowed (`packages/core/tools.` / `…/tools/`). const ref = m[0].replace(/[./]+$/, '') if (existsSync(resolve(root, ref))) continue - // A reference INTO a package's built `lib/` is a build-output path, not an + // A reference INTO a package's built `lib/` is a build OUTPUT, not an // authored-source location: it does not exist until `pnpm run build` emits - // it, and CI runs this gate BEFORE the build step. This gate reports stale - // SOURCE paths (a moved package), so skip `lib/` targets the same way the - // file scan excludes `lib/` files — a `packages/ui/acp-agent/lib/bin.js` - // citation in a built-bin smoke is correct, just not yet on disk at lint. - if (ref.split('/').includes('lib')) continue + // it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when + // the `packages//` ROOT it sits under is real and on disk, so + // `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is + // exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the + // exact moved-package drift this gate exists to catch) still flags. A bare + // `lib` segment is not a blanket escape hatch. + const parts = ref.split('/') + const libAt = parts.indexOf('lib') + if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue // Only a stale path to a REAL (moved) package is a violation; a segment // matching a live package name is the drift signal. const segments = ref.split('/').slice(1)