diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 54510133db..51ddb872c3 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -21,10 +21,11 @@ Independent judgment governs *what to look at* and *how to apply a rule to this These define the conventions and gates this repo is checked against, and they are authoritative. Read them at the source so this skill never drifts out of sync — and apply judgment in *interpreting* them for the case at hand, not in deciding whether they apply. -- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, the empty-`catch` rule, symmetry. -- **AGENTS.md § 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. +- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, no hardcoded tunables in plugins, the empty-`catch` rule, symmetry. +- **[docs/defensive-patterns.md](../../../docs/defensive-patterns.md)** — each section 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 + [docs/AGENTS.md](../../../docs/AGENTS.md)** — the doc-sync rule (code change ⇒ update README + JSDoc in the SAME commit) and the writing rules (current-state-never-history, one line per paragraph, one home per fact, the word-budget gate). - **[packages/AGENTS.md](../../../packages/AGENTS.md)** — per-package conventions (file layout, the HMR-safety test requirement). +- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md) and [docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the authoritative standard for bilingual-doc review: faithfulness, structure, typography, and the binding terminology table. For PRs touching translated docs or pending terms, read these before judging the translation; [dsh-translate-docs](../dsh-translate-docs/SKILL.md) is the translator workflow. - **[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) @@ -34,18 +35,20 @@ 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-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. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (the full gate list is the `doc-sync` script in the root `package.json`), 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 `doc-sync` only covers compilable `ts` blocks, generated-catalog freshness, markdown wrapping/links/refs, verbatim type-equiv blocks, word budgets, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) Where your independent reasoning earns its keep. Start here, then keep going across the broader aspects above. -- **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). +- **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. 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 [docs/testing.md](../../../docs/testing.md)). - **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". +- **Hardcoded tunables that should be plugin config.** A literal timeout, grace period, output/truncation cap, result-count limit, retry count, buffer size, model name, API base URL, user agent, or filesystem path introduced inside a plugin belongs on the plugin's schemastery `Config` with the shipped value as its default (AGENTS.md § Conventions "No hardcoded tunables in plugins"). A named `DEFAULT_*` constant or a test-only injection seam is not configurability — the question to ask is whether a `cordis.yml` deployment can change the value without a code edit. Protocol/wire constants, semantic constants, values pinned by an external spec, and security invariants are exempt; a new `Config` field also needs its README row and range validation. No gate detects a hardcoded tunable — this check is entirely on the reviewer. +- **Test quality — sufficiency, not just coverage.** 100% per-file coverage and a green suite are necessary, not sufficient: they prove the lines *ran*, not that the feature *works the way it ships*. Judge whether the tests are sufficient on two axes. (1) **Would they fail if the behavior regressed?** A test that passes but asserts the wrong thing — or restates the implementation instead of the contract (events fired, disposal reached, the world changed) — is worse than none. (2) **Do they exercise the REAL thing, the way it's actually used?** Prefer the genuine collaborator over a fake, drive the change through its real entry path (the cordis Loader, the ACP bridge, a booted subprocess — not a hand-built `ctx.plugin({...})` that bypasses `unwrapExports`), and verify the WORLD (re-read the file/log/registry externally), not the agent's self-report. A test that fakes the inputs just enough to cover every line will agree with whatever the author assumed; the real thing won't. When a test sets up a *clean/happy* path to reach a line, ask whether the line's PURPOSE is exercised — e.g. a durability/teardown path "tested" by a fully-completed turn never proves the mid-flight teardown it exists for; a torn-tail recovery branch covered by a well-formed log never proves recovery. Flag tests that hit the line but not the scenario. See [docs/testing.md](../../../docs/testing.md) § "Test the real entry path" and § "Prefer the real implementation over a mock". - **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")? +- **Bilingual docs: review translation quality, not just pairing.** If the PR adds or edits a doc pair, read the changed English and Chinese sides and compare the meaning, not only the mechanical diff. Verify terms against [terminology.md](../../../docs/i18n/terminology.md), including first-occurrence annotations and "do not translate as" prohibitions; if a new term has no established precedent, the PR should keep it in English, list it under `待定术语`, and update the terminology table once the rendering is decided. A green `verify-translation-pairing` only proves hashes, switchers, and structure were recorded — it does not prove the translation is faithful, natural, or correctly termed. Treat [translation-rules.md](../../../docs/i18n/translation-rules.md) MUST/MUST NOT violations as blocking. +- **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 [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) "Honor cross-seam contracts on BOTH sides")? ## How to respond diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md new file mode 100644 index 0000000000..7c396ed93c --- /dev/null +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -0,0 +1,47 @@ +--- +name: dsh-doc-standards +description: 'Use when writing, moving, reviewing, or auditing documentation in the deepseek-harness repo — choosing where content belongs, trimming doc slop, responding to a verify-doc-budgets gate failure, or requests like "improve the docs", "audit the docs for slop", "where should this be documented", "this doc is too long".' +--- + +# Applying the DeepSeek Harness Documentation Standard + +The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md) — the tier taxonomy, the word budgets, and the slop checklist. This skill is the workflow for applying it: placing content, auditing the corpus, and handling a red budget gate. It is guidance, not a script; keep judgment active and prefer a few well-proven fixes over a mass rewording pass. + +## Sources of truth (read, don't re-summarize) + +- [docs/AGENTS.md](../../../docs/AGENTS.md) — the taxonomy ("one home per fact"), budgets, slop checklist. +- [docs/rfc/README.md](../../../docs/rfc/README.md) — when a decision earns an RFC, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-rfc-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem. +- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change. +- Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects. + +## Placing content + +Run the placement test in the standard's taxonomy table, then check the constraints that make a placement expensive or wrong: + +- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn. +- Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source. +- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown links, `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments, but nothing catches heading-anchor fragments — grep `#the-heading` across the repo yourself (one anchor is hardcoded in `scripts/gen-cordis-catalog.ts`). +- A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change. + +## Auditing the corpus + +The audit is a hunt for the standard's slop checklist, cheapest probes first: + +1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' | grep -v '^vendor/' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. +2. Hunt narrated history: `rg -n -g '!vendor' -t md "no longer|used to|previously|was moved|renamed"` — judge each hit; some are legitimate (quoting a contrast against a live alternative), most are drift. +3. Hunt duplication: take each standing-doc rule, grep one distinctive phrase from it across all Markdown; more than one home means all but one become links. +4. Hunt catalog restatement: compare README event/tool tables against the generated catalogs and JSDoc; hand copies get replaced by links. +5. Hunt spec-speak in `implemented/` RFCs: migration plans, test checklists, future-tense "should" — an implemented RFC describes what is. The heading-level cases (`## Plan`, `## Acceptance criteria`, …) are mechanically gated by `verify-rfc-format`; the prose-level "should" hunt remains manual. +6. Classify each finding: a mechanical trim lands as a small PR; a restructure or removal that changes what a doc promises gets a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md) for the RFC shape). + +Compression discipline: every load-bearing rule survives — as one to three lines plus a link to the home that carries its why. Cut stories, duplicates, and status annotations; never silently drop a rule. If a cut rule has no durable home to link, create it (usually an RFC or postmortem) in the same change. + +## When verify-doc-budgets goes red + +1. Relocate: does the new content belong in a linked home (RFC, postmortem, cookbook, README) with a one-line pointer left behind? +2. Condense: can existing prose in the doc pay for the addition — a story compressed to its rule, a duplicate converted to a link? +3. Only then raise the ceiling: edit `scripts/doc-budgets.manifest.json` and justify the raise explicitly in the PR description. After any rewrite that shrinks a budgeted doc, ratchet its ceiling down to the new size plus working headroom (at least 5%) in the same PR. + +## Validation and PR hygiene + +For docs-only changes run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; if a paired doc was touched, update the counterpart (see [dsh-translate-docs](../dsh-translate-docs/SKILL.md)) and re-record with `pnpm run verify-translation-pairing --write`. Open a draft PR while the audit is still expanding; in the PR body, list what was trimmed/moved with word deltas, what was deliberately kept long and why, and which checks ran. The first audit cycle's deferred work list lives in [the doc-tiers-and-budgets RFC](../../../docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) § Deferred work. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 2dcea37c66..8fad167a60 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -9,7 +9,7 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba ## 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. +- Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and RFCs-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). - 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/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. diff --git a/.agents/skills/dsh-merging-stacked-prs/SKILL.md b/.agents/skills/dsh-merging-stacked-prs/SKILL.md new file mode 100644 index 0000000000..dc1d2ec265 --- /dev/null +++ b/.agents/skills/dsh-merging-stacked-prs/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dsh-merging-stacked-prs +description: Use when landing a stack of dependent GitHub PRs (A ← B ← C, where each bases on the one below) onto master — merging more than one PR in a chain, merging a PR whose base is another open PR's branch, or whenever a request mentions "stacked PRs", "PR stack", "dependent PRs", "base branch", or merging several related PRs in sequence. Critical because deleting a base branch mid-chain auto-closes the open PR that bases on it — get the order wrong and you silently close unmerged work. +--- + +# Merging a stacked PR chain + +This skill is the landing procedure for a dependent PR stack. The standing orders it rests on — merge commits only (`gh pr merge --merge`), never rewrite a pushed branch — live in the root [AGENTS.md](../../../AGENTS.md) § Conventions; the discipline for handling review comments across a stack before it lands is the [responding-to-pr-review-on-a-stack](../../../docs/cookbook/responding-to-pr-review-on-a-stack.md) cookbook guide. + +## The hazard this prevents + +On GitHub, **deleting a PR's base branch auto-closes that PR.** In a stack `A ← B ← C` (B bases on A, C bases on B), branch A is the base of PR B, and branch B is the base of PR C. So if you merge A with `--delete-branch`, GitHub closes PR B before it's merged — silently destroying the chain. The whole procedure below exists to avoid that: **merge one at a time, retarget each dependent as you go, and delete nothing until every PR has landed.** + +## The procedure + +Given `A ← B ← C` landing on `master`: + +1. **Merge PR A into master, keeping its branch.** `gh pr merge A --merge` — no `--delete-branch`. Branch A must survive because PR B still bases on it. Before touching the next link, confirm the merge actually landed: with required checks pending or a merge queue, `gh pr merge` may only enable auto-merge and return early, so wait until `gh pr view A --json state` reports `MERGED`. This applies after every merge in the stack. + +2. **Retarget PR B, refresh it, then merge it — keeping its branch.** + - `gh pr edit B --base master` (now that A is in master, B's base becomes master). + - Merge the new master *into* branch B: check out B, `git fetch origin`, `git merge origin/master` — merge `origin/master`, not local `master`, because `gh pr merge` updated only GitHub and the local branch is stale — resolve any conflicts here, and push. This makes B current and surfaces conflicts in the working branch where they can be tested — not as a surprise at the GitHub merge. + - `gh pr merge B --merge` — still no `--delete-branch` (PR C bases on branch B). + +3. **Retarget PR C, refresh it, then merge it — keeping its branch.** Same steps: `gh pr edit C --base master`, fetch and merge `origin/master` into branch C, resolve conflicts there and push, then `gh pr merge C --merge` without `--delete-branch`. + +4. **Only after every PR (A, B, C) is merged, delete the branches** — local and remote, for all of A, B, C. + +## Why "merge new master into the dependent before merging it" + +Each retarget step merges the freshly-updated master back into the dependent branch *before* merging the PR. This keeps each PR's diff clean (it only shows that PR's own changes, not the parent's) and forces conflicts to surface in the working branch, where you can build and test the resolution — instead of letting GitHub attempt a blind merge that may conflict or quietly mis-resolve. + +## Verify before deleting anything + +Before deleting a branch, ask GitHub directly whether any open PR still bases on it: + +```sh +gh pr list --state open --base --json number --jq length +``` + +Anything other than `0` means open PRs still base on `` and deleting it would auto-close them — do not delete it. The `--base` filter is applied server-side, so zero-versus-non-zero is exact no matter how many PRs are open; the printed number itself saturates at `gh`'s `--limit` (default 30), which never matters here because only `0` clears a delete. Default to merging without `--delete-branch` throughout, and do the deletions as a separate final pass once every branch you're about to delete reports `0`. + +## Longer chains + +The pattern extends to any depth. For `A ← B ← C ← D ← …`, walk the stack from the bottom up: merge the lowest, then for each next link retarget to master, fetch and merge `origin/master` into it, merge the PR — always without deleting — and only sweep up all the branches at the very end. The invariant never changes: **a branch may be deleted only when no open PR bases on it.** + +## Quick checklist + +- [ ] Merge bottom PR first, `--merge`, no `--delete-branch`; wait until `gh pr view --json state` shows `MERGED`. +- [ ] For each dependent: `gh pr edit --base master` → fetch and merge `origin/master` into the branch (resolve conflicts there, push) → `gh pr merge --merge`, no `--delete-branch`; again wait for `MERGED`. +- [ ] Before each branch delete: `gh pr list --state open --base --json number --jq length` prints `0`. +- [ ] Delete all branches (local + remote) only as a final pass. diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md new file mode 100644 index 0000000000..cde9f00b0e --- /dev/null +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dsh-translate-docs +description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +--- + +# Translating DeepSeek-Harness docs + +**This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. + +## Sources of truth (read, don't re-summarize) + +These are authoritative; read them at the source so this skill never drifts out of sync. + +- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest. +- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). +- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. + +## Find the work + +- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok — the work list for a translation batch. +- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget. + +## Triage by change type + +Do not process every file the same way: + +- **New pair** (no counterpart yet): whichever language exists — English or Chinese — translate the whole file into the other, section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end. +- **Update** (pair exists, one side edited): do NOT re-translate. The consistency record names the exact last-confirmed text of both sides — recover the edited side's previous state and diff: + + ```sh + git cat-file -p > /tmp/last-confirmed.md + git diff --no-index /tmp/last-confirmed.md docs/foo.md + ``` + + Apply the smallest counterpart edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away. +- **Deleted or renamed doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise. + +## Translate + +- Work through the document applying [translation-rules.md](../../../docs/i18n/translation-rules.md). Internally: first render faithfully, then re-read the counterpart alone for awkward or ambiguous phrasing, then polish — but write ONLY the final text to the file, never drafts or notes. +- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, in both directions, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. +- Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. + +## Finish the pair + +1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair. +2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have. +3. New batch landed? Add the `.md` paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. + +## Verify — the gate, not your eyes + +Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report; do not hand-check what they cover. What they can NOT check — whether the two sides truly say the same thing, terminology judgment calls, tone — is exactly what the PR reviewer will read for, so keep the PR reviewable: state which pairs are new vs minimally updated, and list 「待定术语」 prominently. + +## How to respond to translation review + +Same discipline as any review in this repo (see [dsh-code-review](../dsh-code-review/SKILL.md) § How to respond): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9064a38cea..4212717339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,10 +49,10 @@ jobs: # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the # fenced ts blocks against the root project-reference graph. The cordis - # catalog freshness check, type-equiv check, and markdown wrap/link checks - # only read source. Same `doc-sync` script the pre-push hook runs + # catalog freshness check, type-equiv check, Mermaid syntax 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) + - name: Doc-sync gates (doc code blocks + catalogs + mermaid + markdown) run: pnpm run doc-sync # Module-graph freshness: regenerate docs/module-graph.md from the diff --git a/.gitignore b/.gitignore index b52f86cd61..2788817b23 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ examples/*/.sessions/ coverage/ .doc-typecheck-*/ .humanize/ +tmp/ +.claude/commands/ +.claude/settings.json .vscode/ .DS_Store .idea diff --git a/AGENTS.md b/AGENTS.md index b514dd1bed..bfb4060e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,196 +1,58 @@ # AGENTS.md -This is the monorepo for the DeepSeek Harness group. It currently hosts the code for **DeepSeek Code**, DeepSeek's coding agent product. +This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing anything under `packages/` — the service map, event surface, loop lifecycle, and extension seams. The documentation standard is [docs/AGENTS.md](docs/AGENTS.md). ## Pre-release stance: foundation over blast radius -**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 applies only while the harness is unreleased — remove this section at the first tagged release.** There are no external consumers, so optimize for the correct foundation, not a small diff: move files, rename public symbols, repackage plugins, and update every reference in the same change. No backward-compat shims, deprecation aliases, or re-export stubs. On-disk formats need no migrations — a backend REJECTS anything not at the current version. Two sanctioned version stances: monotonic bump-and-reject (the SQLite backend's `SCHEMA_VERSION`), and a pinned `0` that absorbs all shape churn (`SESSION_FORMAT_VERSION` in `dsh-session`, documented "no compatibility implied"). Real version policy begins at the first release. -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 - -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/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. - -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. - -Read [docs/architecture.md](docs/architecture.md) before changing anything under `packages/` — it defines the service map, the event taxonomy, the session/turn/step lifecycle, and the plugin cookbook. - -## Design Documents - -- [Coding Harness MVP 需求分析](https://trtgsjkv6r.feishu.cn/wiki/ZwK6wfBE9i91V6kzMGYcgRGanxg) — requirement analysis for the initial MVP. -- [微内核Harness实现思路](https://trtgsjkv6r.feishu.cn/wiki/VS9Lw1kQki6mDJk2UHocyuphnsc) — discussion of the microkernel plugin-style architecture ("everything is a plugin"). - -## Repository Layout +## Repository layout ``` -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, 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 - 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) - 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 - compact/ compaction capability family - compact/ abstract compaction seam (ctx.compact); backend + tool deferred - subagent/ subagent capability family - subagent/ provider-registry seam (ctx.subagents) - subagent-inprocess/ shared in-process run driver (library, registers nothing) - subagent-spawn/ in-process fresh-child backend - subagent-fork/ in-process backend seeded from the parent's completed-turn prefix - subagent-acp/ out-of-process child over ACP - tool-subagent/ model-facing delegation tool over ctx.subagents - todo/ todo/planning capability family - tool-todo/ model-facing todo_write tool: writes the whole task list to - the session log (todo/write), rendered as a stdio checklist / - ACP plan - 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 - 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) - subagent-mock/ scripted SubagentProvider for deterministic seam/tool 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). Each is a - THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter, - a bash executor), loads ONE app package (dsh-stdio-agent or - dsh-acp-agent), and may add optional product tools or demo-local - teaching plugins. The app package 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 + - subagent + todo_write 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 - 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, - an LLM adapter. -scripts/ repo maintenance scripts (vendor-manifest guard, publint runner). - JS bundling is tsdown (root tsdown.config.ts + two per-package - overrides in vendor/). +vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md +packages/ Harness packages at packages///, all named @deepseek-ai/dsh- + core/ product API spine: session, system-prompt, tools, agent, agent-loop, agent-core (the bundle) + llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) + bash/ bash executor seam + local impl + model-facing bash tools + fs/ filesystem seam + local impl + policy gate + read/write/edit tools + web/ web seam + search/fetch providers + model-facing web tools + compact/ compaction seam + basic backend + subagent/ subagent seam + spawn/fork/ACP backends + delegation tool + todo/ the todo_write tool + hooks/ Claude Code / Codex hook bridges + shared wire-protocol library + session-persistence/ persistence seam + JSONL/SQLite backends + ui/ ACP bridge + app-boot glue + the stdio/ACP app bins + support/ dev/test infrastructure: invariants, llm-replay, subagent-mock + util/ zero-dependency utilities (Branded) +examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) +docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) +scripts/ repo gates and generators ``` +Per-package map: the group READMEs, indexed from [packages/README.md](packages/README.md). + ## Commands ```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: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): - # boot the real acp-agent subprocess, replay a recorded - # session JSONL, diff the normalized stdout + re-persisted - # log against committed goldens. KEYLESS — runs in the - # default gate. Filter one by scenario name (no `--`, which - # vitest treats as a positional file filter): `pnpm run - # test:snapshot -t `. -pnpm run test:snapshot:record # re-record fixtures + goldens against the real - # API (needs DEEPSEEK_API_KEY); accept-the-diff = re-record - # (or `pnpm run test:snapshot -u` to refresh goldens only) -pnpm run typecheck # tsc -b tsconfig.json -pnpm run lint # eslint . -pnpm run lint:fix # eslint . --fix -pnpm run build # tsc emits lib/types, then tsdown bundles runtime lib/index.* -pnpm run knip # dead-code / unused-dependency check -pnpm run publint # package.json publish-correctness check (every packages/*/* package) -pnpm run hygiene # knip + publint + workspace constraints + NodeNext type-consumer check -pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, - # packages/*/*.md + packages/*/*/*.md (doc/code drift gate) -pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md - # (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 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 verify-node-next-types # assert built declarations typecheck for a - # standard external NodeNext ESM TypeScript consumer -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) -pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to - # see a tool call) — the mock skeleton -pnpm run demo:coding # run examples/coding-agent — the real agent (needs - # DEEPSEEK_API_KEY; give it a coding task) -pnpm run demo:acp # run examples/acp-agent — the coding agent as an ACP - # server over JSON-RPC stdio (needs DEEPSEEK_API_KEY; - # drive it from Zed or another ACP client) +pnpm install # pnpm workspaces, node >= 24 +pnpm run test # vitest unit tests +pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src +pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY +pnpm run test:snapshot # keyless ACP replay vs goldens; filter: -t +pnpm run test:snapshot:record # re-record goldens (needs key) +pnpm run typecheck +pnpm run lint +pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check +pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json +pnpm run demo:echo # mock-model REPL, no key needed +pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` -### Run the CI gates locally BEFORE marking a PR ready +### Run the CI gates locally before marking a PR ready -CI is the backstop, not the first place a gate runs. Before you open a non-draft PR or move one from draft to ready, run the same gates CI runs, on your own tree, and confirm they pass — do not lean on CI (or a Codex pass) to discover a red gate you could have caught locally. The CI-equivalent local run is: +CI is the backstop, not the first run. From a fresh clone or worktree, `pnpm run build` first — publint and the NodeNext check validate built `lib/`. The CI-equivalent run: ```sh set -euo pipefail @@ -210,82 +72,50 @@ rm -rf .sessions pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts ``` -**`pnpm run test:coverage`, NOT `pnpm run test`, is the gating test command.** `pnpm run test` runs `vitest run` with no coverage; CI's node job runs `test:coverage`, which enforces a **per-file 100%** threshold on `packages/*/*/src`. A suite that is green under `test` can still fail CI on an uncovered line — and that uncovered line is often *dead code* the 100% gate is correctly flagging for deletion (see [§ Defensive patterns](#defensive-patterns-hard-won) "Line coverage is not behavior coverage"), not a missing test to bolt on. `hygiene` (knip + publint + workspace constraints + NodeNext types) and `test:snapshot` (keyless ACP replay) are likewise CI gates that `test` alone does not cover. When you rely on a Codex convergence pass for sign-off, check WHICH commands it ran: a pass that ran `test` but not `test:coverage`/`hygiene`/`doc-sync` has not exercised those gates. +`test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a review sign-off counts only for the commands it actually ran. ## Secrets / .env -Real-API e2e tests (`pnpm run test:e2e`) read `DEEPSEEK_API_KEY` (and optionally `DEEPSEEK_BASE_URL`) from the environment, or from a gitignored `.env` at the repo root loaded via Node's native `process.loadEnvFile()`: - -``` -DEEPSEEK_API_KEY=sk-… -DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -``` - -cordis.yml configs reference env vars with the `!!js` tag: `apiKey: !!js process.env.DEEPSEEK_API_KEY`. Never commit real credentials; CI has no secrets and e2e suites must self-skip without them. - -**Lean on with-key e2e tests — we are DeepSeek and model inference is cheap.** A no-key test (mock adapter, or an operation that never reaches the model) is great for determinism and CI, but it can only prove the plumbing, not that the agent actually *works* against a real model. Do not ration real-API tests to save tokens: write many of them, cover the real flows (a real prompt that writes a file, a multi-turn conversation, tool use, cancellation mid-stream), and run them frequently while developing — locally and whenever you have a key in the environment. **Especially smoke tests**: a cheap with-key smoke test that boots the real example, sends one real prompt, and checks the world (a file on disk, a non-empty assistant turn) catches whole classes of "green unit tests, broken product" failures that mocks structurally cannot — the very gap that let the ACP inject bug ship (see [docs/postmortem/0001](docs/postmortem/0001-acp-default-export-drops-inject.md)). The self-skip rule is ONLY so CI (which has no secrets) stays green and so a contributor without a key isn't blocked — it is not a signal that real-API tests are expensive or second-class. When in doubt, add the with-key test AND run it. - -Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsconfig.json` (`vitest` resolves through that same root config). Building is only needed for publishing/consumption outside the repo. Non-published code (`examples`, tests, and scripts) is checked by root `tsconfig.json`, which sets `noEmit` and references the package/vendor graph so those sources stay checked under their own tsconfig boundaries. +Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from the environment or a gitignored root `.env` loaded via `process.loadEnvFile()`. cordis.yml references env vars with the `!!js` tag (never `!js`). Never commit credentials. CI has no secrets, so e2e suites self-skip without a key — a CI accommodation, not a cost signal; the with-key policy is in [docs/testing.md](docs/testing.md). ## Conventions -- **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-` (vendored packages keep their upstream names and are `private: true`). -- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports use explicit `.ts` extensions; `rewriteRelativeImportExtensions` turns those into `.js` in emitted JS, while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve to sibling `.d.ts` files. `lib/types/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. -- **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention. -- **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer. -- **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`). -- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits. This is the veto mechanism — use deliberately. -- **Discriminated unions: match, don't chain**: branch on a tagged union (`StreamChunk`, `FinishReason`, `SessionEvent`, …) with a `switch` on the tag, not a chain of `if (x.kind === '…')`. The switch narrows each arm so member-only fields (`finish.message`, `finish.code`) are reachable in the right case and a typo'd tag fails to compile. Prefer extracting a small typed helper (`finishError(finish: FinishReason)`) over inlining the branches at the call site. -- **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`) end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a variant breaks compilation at every switch that must handle it. Switches over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, `FinishReason`, …) must NOT use assertNever — plugin-added variants are valid unknown values; handle known cases and fall through `default` with a comment (the lint rule `switch-exhaustiveness-check` makes the choice explicit either way; a redundant disable directive is itself a lint error). -- **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc. -- **Capability seams are three packages**: when adding a swappable capability (an execution backend, a provider integration, …), split it into *interface* (abstract service + vocabulary types, e.g. `bash/`), *implementation* (a concrete subclass, e.g. `bash-local/`), and *consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations and consumers then evolve independently — a sandboxed executor replaces `bash-local` without touching tool schemas. The LLM seam follows the same shape (`llm/` is interface + consumer surface; adapters are implementations). See docs/architecture.md § "Capability seams" for when NOT to split. -- **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. -- **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. -- **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. +- Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; building is only for consumers outside the repo. +- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md). +- **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment. +- **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). +- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. +- **Capability seams are three packages** — interface / implementation / consumer ([capability seams](docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)); don't split preemptively. +- **Explicit > implicit at package seams**: no optional field silently filled by a hidden `?? default` inside `run()`; defaulting is an explicit `resolve(request): Spec` step in the owning implementation (the `dsh-bash` request/spec split is the template). +- **No hardcoded tunables in plugins**: anything two deployments could want different — timeouts, caps, grace periods, model names, base URLs — is a defaulted, validated `Config` field, not a literal; a `DEFAULT_*` constant or test-only seam is not configurability. The test: changeable from `cordis.yml`, no code edit. Protocol/wire constants, external-spec values, security invariants stay hardcoded. +- **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string` ([branded IDs](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)). +- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. +- **Symmetry is usually more correct**: parallel values get parallel form; asymmetry is a smell for a missed extraction. +- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR ([worked example](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md)). +- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/` ([worked example](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **Testing policy** — tiers, with-key generosity, real-over-mock, world-verification, real-load-path and published-bin guards: [docs/testing.md](docs/testing.md). A transcript/UX-affecting change needs a snapshot test, or a PR note why none applies. +- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([render-intent RFC](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), [cookbook](docs/cookbook/adding-a-tool.md)). +- **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise. +- **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). +- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. -## Defensive patterns (hard-won) +## Defensive patterns -Each bullet is a bug class that bit us; the rule prevents the reoccurrence. +[docs/defensive-patterns.md](docs/defensive-patterns.md) carries the hard-won bug-class rules: report orthogonal outcomes independently; honor cross-seam contracts on both sides; async state is not synchronous state; dispose must reach quiescence; contain callback exceptions; never hand untrusted output the ambient environment or predictable paths. Read it before lifecycle, concurrency, subprocess, or teardown work. -- **Report orthogonal outcomes independently.** A result can be several things at once (a process can both time out AND exit 0 because it trapped the signal). Don't nest the report of one flag inside the branch of another. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own so a caller never reads a cut-short run as a clean success. -- **Honor cross-seam contracts on BOTH sides.** When an interface documents two valid ways to signal something (e.g. an adapter may report a model failure by THROWING from `stream()` *or* by ending the stream with a `finish {kind:'error'|'aborted'}` chunk), the consumer must handle both — not just the one the first implementation happened to use. A library-backed adapter that can't throw mid-stream relies on the finish-chunk path; if the loop only catches throws, a provider 401 becomes a normal completed turn. Document the contract where the type is defined and exercise every branch through the real consumer in tests. -- **Async state is not synchronous state.** `agent.send()` does not flip status to `running` before it returns; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only *just* requested. Drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and when "done" needs a settle signal, observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns — the loop batches queued messages into one turn. But a settle-signal guard cuts both ways: if the awaited transition can *never* occur (EOF with no work submitted → no turn ever starts → never `running`), it hangs forever. Always handle the "nothing to wait for" branch explicitly alongside the "wait for the work" branch. -- **Dispose must reach quiescence, not just request it.** A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup `async` and `await` the children's exit (kill → await `done`), and close listener/notification registries *before* killing so late completions stay silent. Tests must prove disposal *waited* (pid already gone right after `await fiber.dispose()`), not merely that the process eventually dies. -- **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. 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 -## Type Safety and Documentation +Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example). -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). +Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). -**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. +## Editing these instructions -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. +`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. This file is budget-gated (`verify-doc-budgets`): additions displace something or justify a ceiling raise in the PR. -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. +## Vendoring policy -**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|serial` 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 and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), 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. - -**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/*/*.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 plus `examples/**/*.md` and `.agents/skills/**/*.md` resolves. - -**Editing these instructions**: `AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (at the repo root and in `packages/` / `examples/`). Always edit `AGENTS.md` — never write through the `CLAUDE.md` symlink or replace it with a regular file. - -## Vendoring Policy - -`vendor/` packages are pinned source copies (manifest with upstream commit SHAs in [vendor/README.md](vendor/README.md)). To update one, follow the sync procedure there; re-apply (or retire) the logged local modifications and rerun `pnpm run test && pnpm run build`. +`vendor/` packages are pinned source copies (manifest with upstream SHAs in [vendor/README.md](vendor/README.md)). Update via the sync procedure there; re-apply or retire the logged local modifications; rerun `pnpm run test && pnpm run build`. diff --git a/README.i18n.yaml b/README.i18n.yaml new file mode 100644 index 0000000000..db27519f15 --- /dev/null +++ b/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 53dd3896eb15800125673e7c44f7de02daca9376 +README.zh.md: 5de4c5b6804648f061647d9e315c08a32b42b39b diff --git a/README.md b/README.md index 1ce5aa8960..53dd3896eb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # DeepSeek Harness -Monorepo for the DeepSeek Harness group. +English | [中文](README.zh.md) -## Projects - -- **DeepSeek Code** — DeepSeek's coding agent product. +The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses. ## Development @@ -13,10 +11,10 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # runnable echo-agent example (no API key needed) -pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` -For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). +For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). For agents, follow [AGENTS.md](AGENTS.md). diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000000..5de4c5b680 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,20 @@ +# DeepSeek Harness + +[English](README.md) | 中文 + +**DeepSeek Harness SDK** 是用于构建 agent harness 的 SDK,采取基于插件的设计。 + +## 开发 + +本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:一切皆插件。 + +```sh +pnpm install +pnpm run test # vitest +pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) +``` + +面向人类读者:先读[开发指南](docs/development.md)了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 + +面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 7571209aa2..5bebb52651 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,15 +1,60 @@ -# AGENTS.md — Docs +# AGENTS.md — The documentation standard -Conventions for authoring everything under `docs/` (architecture, RFCs, cookbook, ADRs-now-RFCs). The repo-wide Markdown rules in the root [AGENTS.md](../AGENTS.md) § "Type Safety and Documentation" still apply (one physical line per paragraph, fenced `ts` blocks must compile); the points below are docs-specific. +This file is the contract for every Markdown surface in the repo: each tier's job, the writing rules, and the word budgets the `verify-doc-budgets` gate enforces. The audit/apply workflow is the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill; the decision record is [the doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md). + +## The tier taxonomy: one home per fact + +Every fact has exactly one home — the tier whose job it is — and every other place that needs it links there instead of restating it. A rule restated in two files drifts word-by-word until the copies disagree; a link cannot drift, and `verify-md-links` keeps it resolving. + +| Tier | Job | Does NOT belong there | +|---|---|---| +| Root `AGENTS.md` | Standing orders: rules an agent needs in context in every session, one to three lines each, linking its home | Stories, worked examples, situational procedures, anything restated from a linked home | +| Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | +| [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ RFCs), implementation-status annotations | +| [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) | +| [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped | +| [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | +| [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | +| Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | +| [development.md](development.md) | Human-facing setup and daily workflow; a bilingual pair under the [i18n contract](i18n/README.md) | Gate-by-gate enumerations that drift from `package.json` scripts | +| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog/tools.md), [persistence-catalog](persistence-catalog/log-events.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | +| Skills (`.agents/skills/`) | Workflows: how to carry out a recurring task against the contracts | The contracts themselves (→ docs) | + +Placement test: a story about a bug → postmortem. Why we chose X → RFC. How to do task Y → cookbook. What type Z looks like → core-data-structures. What package P promises → its README. A rule every agent must always obey → root AGENTS.md, one line, linking the home that holds the why. + +## Writing rules + +- **Document the current state — never the process or history that produced it.** Prose describes what the code IS and why, as if it had always been so: no "previously/now/no longer/used to/renamed/moved here", and never name a change unit the reader cannot see — a PR, commit, or stack position — in comments, JSDoc, or test names; name the mechanism instead. A genuinely clarifying contrast is framed against the live alternative as a standing fact, not against the past. The change story belongs in the commit message, the PR description, or an RFC. +- **A decision worth re-litigating gets an RFC in the same PR.** The test: would a maintainer six months out ask "why was it done this way?" and find no answer in the code? If yes, write one ([when to write one](rfc/README.md)); mechanical or self-evident changes need none. +- **One physical line per paragraph** (`verify-md-wrap`): the editor soft-wraps; hard breaks make a one-word edit re-diff the whole paragraph. Prose only — code blocks, tables, and list structure stay; code comments stay under the linter's column limit. +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)). +- **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). +- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). + +## Budgets and the ceiling gate + +Standing docs accrete: every PR has a lesson it wants to append, and without displacement pressure nothing ever leaves. The gate is that pressure. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) lists the accretion-prone standing docs with a word ceiling each; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget. + +- Ceilings are an enforcement frontier with working headroom: a ceiling sits at least 5% above the doc's current size — routine edits pass, real growth trips the gate — and ratchets down, keeping the margin, as the doc reaches target. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file (which carries the standard) ≤ 1,250; `packages/README.md` ≤ 600. +- When the gate goes red, the fix is to relocate or condense per the taxonomy above. Raising a ceiling is the last resort: the PR must justify it; the manifest diff is the reviewable act. +- Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling — length is legitimate there when every row is a fact. Review and the slop checklist govern them instead. + +## The slop checklist + +Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill runs this list as an audit: + +- The same rule stated in more than one home. Grep a distinctive phrase; keep one home, convert the rest to links. +- Narrated history: "previously", "now", "no longer", "used to", "renamed", "was moved", references to PRs or commits. State the current fact; the why belongs in an RFC, the story in a postmortem or git. +- A war story told inline where a one-line rule plus a postmortem/RFC link would do. +- Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it. +- Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead. +- Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home. +- Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior. +- Spec-speak in `implemented/` RFCs: "should", migration plans, acceptance checklists. An implemented RFC describes what is, per [rfc/implemented/AGENTS.md](rfc/implemented/AGENTS.md). ## 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/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. +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 — never bare prose or a number ("see RFC 005"), which is uncheckable and rots on rename. `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 when a relative target does not exist, so a rename that orphans a link is caught before review. This is also why RFC files carry dates and topics instead of stable numbers: they survive moves between lifecycle and class folders without dangling references. -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 (`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. +The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one. diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md new file mode 100644 index 0000000000..d6d9ab1ba7 --- /dev/null +++ b/docs/agent-lifecycle.md @@ -0,0 +1,49 @@ + + +# Agent Turn And Step Lifecycle + +This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`. + +```mermaid +sequenceDiagram + participant User + participant Agent + participant Driver + participant Hooks as hook listeners + participant Prompt as ctx.systemPrompt + participant LLM as ctx.llm + participant Tools as ctx.tools + participant Session + participant Persistence + participant SDK as UI or SDK listener + User->>Agent: send(content) + Agent-->>SDK: agent/queued + Agent->>Driver: queued work wakes driver + Driver-->>SDK: agent/status running + Driver->>Session: turn/start + Driver->>Hooks: agent/prompt-submit waterfall + Hooks-->>Driver: allow, block, or add context + Driver->>Session: user/message or rejected turn/end + Driver->>Prompt: system-prompt/assemble waterfall + Driver-->>Driver: agent/pre-step serial checkpoint + Driver->>Session: step/start + Driver->>LLM: agent/request waterfall, then llm/stream waterfall + LLM-->>Driver: StreamChunk* + Driver->>Session: assistant/chunk* + Session-->>SDK: session/event assistant/chunk* + Driver->>Hooks: agent/step-result waterfall + Driver->>Session: assistant/message + Driver->>Session: tool/call + Driver->>Tools: execute through pre and post waterfalls + Tools-->>Session: tool-owned events when applicable + Driver->>Session: tool/result and step/end + Driver->>Hooks: agent/turn-continuation waterfall + Driver->>Session: turn/end + Driver->>Persistence: session/flush parallel checkpoint + Driver-->>SDK: agent/status idle +``` + +SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. + +Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/architecture.md b/docs/architecture.md index 00722e4e8b..da6617b777 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,233 +1,149 @@ # DeepSeek Harness Architecture -This document describes the phase-1 architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle, from the [microkernel design discussion][microkernel-doc], is: +The **DeepSeek Harness SDK** is an SDK for building agent harnesses using the Cordis framework. The governing principle is simple: **everything is a plugin**. For example, the shipped agent loop is just one plugin in the default bundle, not a privileged kernel. -> **Microkernel approach. Everything is a plugin.** +Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). If Cordis itself is new to you, start with the [Cordis primer](cordis-primer.md). -The harness core is deliberately tiny: a handful of abstract services plus one concrete loop plugin (`dsh-agent-loop`). Every product feature — tools, hooks, compaction, sandboxing, UI, persistence, sub-agents, MCP, skills — is meant to be written as a plugin against the extension surface described here, without modifying the loop. +## System Shape -Requirement context: [Coding Harness MVP 需求分析][mvp-doc]. +A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services are the stable call surfaces (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners. -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. +The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins from a Cordis perspective. -**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) +### Default Service Spine -[microkernel-doc]: https://trtgsjkv6r.feishu.cn/wiki/VS9Lw1kQki6mDJk2UHocyuphnsc -[mvp-doc]: https://trtgsjkv6r.feishu.cn/wiki/ZwK6wfBE9i91V6kzMGYcgRGanxg +| ctx key | Package | Role | +|---|---|---| +| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | +| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | +| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | +| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` vocabulary | +| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | -## Layering +### Capability Services -``` -┌─────────────────────────────────────────────────────────────┐ -│ future plugins: hooks, compaction, sandbox, UI, MCP… │ -├─────────────────────────────────────────────────────────────┤ -│ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │ -│ @deepseek-ai/dsh-bash-local (bash impl) │ -│ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ -│ @deepseek-ai/dsh-subagent-* (subagent providers) │ -│ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ -├─────────────────────────────────────────────────────────────┤ -│ @deepseek-ai/dsh-agent (vocabulary + registry) │ -│ @deepseek-ai/dsh-tools (registry + exec waterfall)│ -│ @deepseek-ai/dsh-system-prompt (assembly registry) │ -│ @deepseek-ai/dsh-session (event-sourced log) │ -│ @deepseek-ai/dsh-session-persistence (persistence seam) │ -│ @deepseek-ai/dsh-llm (abstract model service) │ -│ @deepseek-ai/dsh-bash (abstract bash executor) │ -│ @deepseek-ai/dsh-compact (abstract compaction seam) │ -│ @deepseek-ai/dsh-subagent (provider registry seam) │ -├─────────────────────────────────────────────────────────────┤ -│ vendor/: cordis, loader, include, group, timer, hmr, │ -│ logger-console, cosmokit, schemastery │ -└─────────────────────────────────────────────────────────────┘ -``` +| ctx key | Package family | Role | +|---|---|---| +| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | +| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | +| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | +| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | +| `ctx.sessionFork` | [`session-fork/`](../packages/session-fork/README.md) | live-session fork boundary validation and seed snapshots | +| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -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. +## Event Surface -## Service map +Events are the harness extension API. Each service owns the vocabulary for the behavior it controls, and the generated [events catalog](cordis-catalog/events.md) is the exhaustive reference. The [producer/consumer map](event-producer-consumer.md) shows which packages emit or listen to each event. -| ctx key | Class | Package | Role | -|---|---|---|---| -| `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()` | -| `ctx.tools` | `ToolRegistry` | dsh-tools | tool definitions; `execute()` through waterfall | -| `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 | -| `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | -| `ctx.sessionFork` | `SessionForkService` | dsh-session-fork | live-session fork seam: validate turn-boundary forks, snapshot seed events, create forked child sessions | -| `ctx.subagents` | `SubagentService` | dsh-subagent | named provider registry for delegating a task to child agents | +### Event Domains -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. +Use the event domain to decide where new behavior belongs: -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. +- **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. +- **Agent events** are live runtime surfaces. They carry the live `Agent` handle for status, diagnostics, prompt admission, request mutation, result validation, and continuation policy. +- **Capability events** belong to the seam that owns the action. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` let policy and adapters attach without importing the loop. -## Capability seams: interface / implementation / consumer +### Interception Semantics -Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template: +Waterfall events behave like around-middleware: a listener delegates by calling `next()` and vetoes or takes over by returning without it. The full rule lives in [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). -1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary types (`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract, owns the `ctx.bash` key, depends only on cordis. -2. **Implementation** (`dsh-bash-local`) — a concrete subclass loaded as a plugin (local subprocesses, process-group kills, spill-file truncation). Sandboxed, containerized, or remote backends are sibling packages implementing the same interface. -3. **Consumer** (`dsh-tool-bash`) — what the model and other plugins program against (the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers `inject` the interface's ctx key and never import implementation types. +## Default Loop Lifecycle -The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. +The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam that another plugin can program against. -> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. +A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension seams. -## The vocabulary (dsh-llm) +### Turn Flow -Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings. - -Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding the same chunks through an assembler. - -`LlmAdapter` is the provider seam: subclass, implement `stream()`, call `ctx.llm.registerAdapter(models, adapter)`. Two real adapters implement it — `dsh-llm-deepseek` (hand-rolled fetch/SSE against the DeepSeek API) and `dsh-llm-pi-ai` (the same endpoint through the `@earendil-works/pi-ai` library). They exist as a pair deliberately: two independent internals over one contract verified the StreamChunk protocol, which is now documented (in `dsh-llm/src/types.ts`) with the conventions that review pinned down — usage before finish, nothing after finish, raw-string tool arguments, and the two sanctioned error paths (thrown vs `finish {kind:'error'}`). - -## Event-sourced sessions (dsh-session) - -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; 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. Live-adapter review has validated the tagged-envelope rendering against current DeepSeek behavior; provider-specific mismatches belong in that adapter. - -Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`; user-facing live-session fork policy lives in the optional `ctx.sessionFork` service, which rejects non-boundary forks instead of changing the core store. 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 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, seed boundary) 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, source_event_seqs, surface_op)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic. - -## Prompt assembly (dsh-system-prompt) - -Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall. - -Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text. - -## Tool pipeline (dsh-tools) - -`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its schemas into the system-prompt assembly automatically. - -`execute()` runs through the **`tools/execute` waterfall** — the single seam where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call. This collapses Claude Code's validate → PreToolUse → permission → execute → PostToolUse pipeline into ordered waterfall listeners. - -**TODO**: tool shapes get revisited now that real tools exist (the bash suite landed; the `TODO(review)` in dsh-tools is still open) — e.g. a concurrency-safety hint for parallel execution; phase 1 executes tool calls sequentially. - -## Agents (dsh-agent) and the loop (dsh-agent-loop) - -`Agent` is the handle every plugin programs against: - -- `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)). -- `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). 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` - -**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. - -### Loop lifecycle (session / turn / step) - -- **Session**: the whole event log of one agent. -- **Turn**: triggered by ≥1 queued message; runs steps until the model stops requesting tools and no plugin requests continuation. -- **Step**: one model request + its tool executions. - -``` +```text +create agent -> emit agent/session-start(source) forever: - wait for queued messages (idle) + wait for queued messages emit agent/status(running) - TURN (error-contained — a throwing plugin ends the turn, never the loop): - drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start + TURN: + 'turn/start' + each queued message -> agent/prompt-submit + allowed prompt -> 'user/message' plus injected context + every prompt blocked -> 'turn/end'(rejected) STEP loop: - drain steering (late steering from previous step's listeners) - assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble - await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step - session('step/start'); emit agent/step-start - req = {model, system, tools, messages: session.deriveMessages(), signal} - req = waterfall agent/request ⟵ hooks, model switch - stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) - session('assistant/chunk'); emit agent/stream-chunk - if assembler.finish is error/aborted: throw ⟵ adapter's in-band error path → - 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' {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 - tool execution may append tool-owned session events, e.g. `todo/write` - session('tool/result') - drain steering → session('steering/message'); emit agent/steering - emit agent/step-end - cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) - steering pending from step-end/continuation listeners forces cont = true - if !cont: break - session('turn/end'); emit agent/turn-end - await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure - reported via agent/error, not fatal) - leftover steering re-enqueued as queued messages ⟵ steering is never stranded - emit agent/status(idle) unless more queued + drain steering + assemble system prompt and tool schemas + agent/pre-step + 'step/start' + derive messages from the session log + agent/request -> llm/stream + 'assistant/chunk' + agent/step-result + 'assistant/message' + each tool call: + 'tool/call' + tools/pre-execute -> dispatch -> tools/post-execute + 'tool/result' + append post-tool context and steering + 'step/end' + agent/turn-continuation + stop unless tools or continuation policy ask for another step + 'turn/end' + checkpoint persistence and notify idle/running status ``` -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')`. +Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` itself owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, from its `persona` config, shared by every agent in the context) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -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. +Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input. -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. +### Failure Boundaries -**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). +The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends the current turn with an error reason and reports live diagnostics through `agent/error`; it does not kill the driver loop. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the appropriate turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and lets service disposers drain. -### Event taxonomy +Every session event is turn-enclosed. Reloading a crashed session preserves the interrupted tail and closes it with a synthetic `interrupted` turn end. A failure after the durable turn has closed reports through `agent/error` only because no safe in-turn position remains. A turn ends with one `TurnEndReason` (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`); per-variant semantics are in [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). -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. +### Agent Handles -### Cordis waterfall semantics (important) +`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`. -`ctx.waterfall` is **around-middleware**, not a value reducer. Each listener receives `(...args, next)`: +## State And Model Surface -- call `next()` to delegate to later listeners (and ultimately the core behavior), possibly wrapping it; -- return a value **without** calling `next()` to short-circuit (veto); -- listeners run in registration order; `prepend: true` jumps the queue. +### Session Log -Composition caveat: values propagate through `next()`'s **return value**. Mutating the passed-in object works when later listeners receive the same reference, but a listener that returns a *new* object makes earlier mutations invisible downstream. Prefer mutate-then-`next()` for cooperative middleware; return a replacement only when you mean to take over the result. +The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream. -## Plugin sanity checklist +The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The optional `ctx.sessionFork` service owns live-session fork policy: it validates that a source session is empty or at a turn boundary, snapshots the seed, and creates child metadata without changing the core log. -Every MVP feature (including the TODO-marked ones), with the mechanism that implements it **without modifying the loop**: +Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. -| MVP feature | Plugin mechanism | +### Model Content + +Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, compaction pricing, and persistence, so block vocabulary remains a repo-wide contract. + +Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAssembler` as the shared chunk-to-block assembler. The loop logs raw chunks while assembling them for dispatch. `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register with `ctx.llm.registerAdapter(models, adapter)`. StreamChunk conventions live in [llm-streaming.md](core-data-structures/llm-streaming.md). + +## Extension And Composition + +### Capability Pattern + +A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families. + +Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Session-fork keeps its interface and implementation together because it delegates durable work to the existing session store. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)). + +### Bundles And Apps + +`dsh-agent-core` is the default composition bundle: one plugin loading the providerless spine as code ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and own the boot `bin`: `dsh-stdio-agent` for the terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). + +### Where New Behavior Goes + +New behavior should attach to a documented seam; changing the shipped loop requires updating this map. + +| Goal | Mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`; a hooks plugin bridges config files to shell commands | -| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | -| `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | -| Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | -| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | -| Session fork | the `ctx.sessionFork` seam ([dsh-session-fork](../packages/session-fork/session-fork)): validate the source is at a turn boundary, snapshot its seed, and create a child session with `parentSession`/`seedLength` metadata. | -| System prompt configurability | `ctx.systemPrompt.section()` with ordering | -| AGENTS.md (root) | a section provider reading the file | -| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | -| Built-in tools (Read/Write/Edit/Bash/…) | `ctx.tools.register()`; schemas flow into the assembly automatically. **Bash: implemented** — `dsh-bash` (seam) + `dsh-bash-local` (subprocesses) + `dsh-tool-bash` (`bash`/`bash_output`/`bash_kill`, incl. background tasks). **`todo_write`: implemented** — `dsh-tool-todo` writes the whole task list to the session log (`todo/write`), rendered as a stdio checklist / ACP `plan` | -| ToolSearch / progressive disclosure | wrap `agent/request`, filter `req.tools` | -| Tool sandbox (landlock / sandbox-exec) | wrap `tools/execute`, or implement a sandboxing `BashExecutor` (the dsh-bash seam) | -| Permission system / AskUserQuestion | wrap `tools/execute` (veto or ask); register an ask tool | -| Plan mode | wrap `tools/execute` (deny writes) + `agent/request` (inject mode prompt) | -| Sub-agent delegation | Implemented as the `ctx.subagents` provider-registry seam: `dsh-subagent-spawn` starts a fresh in-process child, `dsh-subagent-fork` seeds a child from the parent's completed-turn prefix, `dsh-subagent-acp` drives an out-of-process child over ACP, and `dsh-tool-subagent` exposes one configured provider to the model | -| MCP | one plugin per server: discover tools → `ctx.tools.register()` | -| Skills | section + tool registration; `inject()` skill content on invocation | -| Memory | section provider + tool | -| Scheduled tasks (cron) | plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | -| UI (GUI; CLI emits JSONL) | listen `agent/stream-chunk` + `session/event`; input → `send()` | -| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | -| DeepSeek V4 (and other) models | `LlmAdapter` subclass via `registerAdapter`. **Implemented twice**: `dsh-llm-deepseek` (hand-rolled) and `dsh-llm-pi-ai` (pi-ai-backed) | -| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | +| Add a model provider | register an adapter on `ctx.llm` | +| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | +| Add command execution | implement and register a `ctx.bash` backend | +| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | +| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | +| Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Add durable session state | add a `SessionEventMap` member and render/replay from the log | +| Fork a live session | use `ctx.sessionFork` to validate the boundary and create a seeded child session | -## Extension cookbook - -Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and the two runnable example wirings live in [docs/cookbook/extension-cookbook.md](./cookbook/extension-cookbook.md). Step-by-step guides: [adding a package](./cookbook/adding-a-package.md), [adding a tool](./cookbook/adding-a-tool.md), [adding an LLM adapter](./cookbook/adding-an-llm-adapter.md), [adding a vendored package](./cookbook/adding-a-vendored-package.md). - -## Deferred work (TODO) - -Tracked here deliberately — each is designed-for but not implemented: - -- **Inter-agent channels beyond delegation** (shared state, streaming child output, background/poll semantics) remain out of scope for the current `ctx.subagents` seam. -- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the serial `agent/pre-step` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). -- **Parallel tool execution** (concurrency-safety hints on ToolDefinition). -- **Session branching/tree** (pi-style entry tree) if needed beyond the current seed-based `ctx.sessionFork` service. +The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/capability-seams.md b/docs/capability-seams.md new file mode 100644 index 0000000000..414ee898d1 --- /dev/null +++ b/docs/capability-seams.md @@ -0,0 +1,142 @@ + + +# Capability Seams And Core Services + +A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly. + +```mermaid +flowchart LR + pkg_llm["llm"] + svc_llm["ctx.llm
LLM adapter registry"] + pkg_llm_deepseek["llm-deepseek"] + pkg_llm_pi_ai["llm-pi-ai"] + pkg_llm_replay["llm-replay"] + pkg_agent_loop["agent-loop"] + pkg_compact_basic["compact-basic"] + pkg_session["session"] + svc_sessions["ctx.sessions
In-memory session store"] + pkg_agent["agent"] + pkg_session_persistence["session-persistence"] + pkg_subagent_inprocess["subagent-inprocess"] + pkg_invariants["invariants"] + svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] + pkg_session_persistence_jsonl["session-persistence-jsonl"] + pkg_session_persistence_sqlite["session-persistence-sqlite"] + pkg_acp["acp"] + pkg_system_prompt["system-prompt"] + svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] + pkg_tools["tools"] + pkg_tool_fs["tool-fs"] + pkg_tool_web["tool-web"] + svc_tools["ctx.tools
Tool registry and execution waterfall"] + pkg_tool_bash["tool-bash"] + pkg_tool_subagent["tool-subagent"] + pkg_tool_todo["tool-todo"] + svc_agents["ctx.agents
Agent registry"] + pkg_stdio_agent["stdio-agent"] + svc_agentLoop["ctx.agentLoop
Concrete loop driver"] + pkg_agent_core["agent-core"] + pkg_bash["bash"] + svc_bash["ctx.bash
Bash executor seam"] + pkg_bash_local["bash-local"] + pkg_hooks_claude["hooks-claude"] + pkg_hooks_codex["hooks-codex"] + pkg_fs["fs"] + svc_fs["ctx.fs
Filesystem provider seam"] + pkg_fs_local["fs-local"] + pkg_fs_policy["fs-policy"] + pkg_compact["compact"] + svc_compact["ctx.compact
Compaction seam"] + pkg_subagent["subagent"] + svc_subagents["ctx.subagents
Subagent provider registry"] + pkg_subagent_spawn["subagent-spawn"] + pkg_subagent_fork["subagent-fork"] + pkg_subagent_acp["subagent-acp"] + pkg_subagent_mock["subagent-mock"] + pkg_web["web"] + svc_web["ctx.web
Web access provider registry"] + pkg_web_search_exa["web-search-exa"] + pkg_web_search_perplexity["web-search-perplexity"] + pkg_web_search_deepseek["web-search-deepseek"] + pkg_web_fetch_local["web-fetch-local"] + pkg_agent --> svc_agents + pkg_agent_loop --> svc_agentLoop + pkg_bash --> svc_bash + pkg_bash_local --> svc_bash + pkg_compact --> svc_compact + pkg_compact_basic --> svc_compact + pkg_fs --> svc_fs + pkg_fs_local --> svc_fs + pkg_llm --> svc_llm + pkg_llm_deepseek --> svc_llm + pkg_llm_pi_ai --> svc_llm + pkg_llm_replay --> svc_llm + pkg_session --> svc_sessions + pkg_session_persistence --> svc_sessionPersistence + pkg_session_persistence_jsonl --> svc_sessionPersistence + pkg_session_persistence_sqlite --> svc_sessionPersistence + pkg_subagent --> svc_subagents + pkg_subagent_acp --> svc_subagents + pkg_subagent_fork --> svc_subagents + pkg_subagent_mock --> svc_subagents + pkg_subagent_spawn --> svc_subagents + pkg_system_prompt --> svc_systemPrompt + pkg_tools --> svc_tools + pkg_web --> svc_web + pkg_web_fetch_local --> svc_web + pkg_web_search_deepseek --> svc_web + pkg_web_search_exa --> svc_web + pkg_web_search_perplexity --> svc_web + svc_agentLoop --> pkg_agent_core + svc_agents --> pkg_acp + svc_agents --> pkg_agent_loop + svc_agents --> pkg_invariants + svc_agents --> pkg_stdio_agent + svc_agents --> pkg_subagent_inprocess + svc_bash --> pkg_hooks_claude + svc_bash --> pkg_hooks_codex + svc_bash --> pkg_tool_bash + svc_compact --> pkg_compact_basic + svc_fs --> pkg_tool_fs + svc_llm --> pkg_agent_loop + svc_llm --> pkg_compact_basic + svc_sessionPersistence --> pkg_acp + svc_sessionPersistence --> pkg_agent_loop + svc_sessions --> pkg_agent + svc_sessions --> pkg_agent_loop + svc_sessions --> pkg_invariants + svc_sessions --> pkg_session_persistence + svc_sessions --> pkg_subagent_inprocess + svc_subagents --> pkg_tool_subagent + svc_systemPrompt --> pkg_agent_loop + svc_systemPrompt --> pkg_tool_fs + svc_systemPrompt --> pkg_tool_web + svc_systemPrompt --> pkg_tools + svc_tools --> pkg_acp + svc_tools --> pkg_agent_loop + svc_tools --> pkg_tool_bash + svc_tools --> pkg_tool_fs + svc_tools --> pkg_tool_subagent + svc_tools --> pkg_tool_todo + svc_tools --> pkg_tool_web + svc_web --> pkg_tool_web + svc_fs -. event gate .-> pkg_fs_policy +``` + +| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | +| --- | --- | --- | --- | --- | --- | --- | +| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | +| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | +| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. | +| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | +| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | + +Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index ef5f48d624..1ab6931696 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -46,4 +46,4 @@ pnpm run test:coverage # 100% per-file over src (types.ts exempt) pnpm run build && pnpm run hygiene ``` -Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see AGENTS.md. +Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see [docs/testing.md](../testing.md). diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 73706c84f1..9180d88489 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -36,6 +36,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **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. +- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. - **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). ## Long-running work @@ -46,8 +47,28 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task ## Permissions / sandboxing -Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)), or a sandboxing implementation behind the tool's executor seam. +Prefer not to build policy into the tool. The seam is the `tools/pre-execute` gate (deny/ask — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)) and the `tools/post-execute` inspect/transform seam, or a sandboxing implementation behind the tool's executor seam. + +## How your tool renders in an editor (ACP presentation) + +Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input). + +Both methods return a **`card`-tagged render intent** — pick the card kind that matches what your tool does: + +- `presentCall(args)` → a `ToolCallView` (the PENDING card): + - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. + - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) + - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) +- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create; `write`/`edit` attach the hunks via the `meta` channel and read them back here). A mutation tool returns the `diff` result even when it duplicates the call-time card, because an ACP `tool_call_update.content` REPLACES the call's content — a non-diff result would clobber the pending diff. `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. + +Hard rules (they bite if broken): + +- **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs on the bridge or a future result-event shape, not the presenter. +- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path — none of these may appear in what `execute` returns to the model; they live only in the presentation. (A `terminal` result view carries RAW `output`; the bridge adds the fences.) +- **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. + +The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a tool); the ACP bridge maps each `card` to the wire. The design and the why are in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. ## 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/core/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. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path. diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index aae6b0bb8b..fb59969e52 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -27,7 +27,7 @@ Registration is effect-based (HMR-safe); one adapter per model name — duplicat - Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block. - Errors have exactly two sanctioned paths: THROW from `stream()` (transport and protocol failures — use `LlmError` with a stable code), or end the stream with `finish {kind: 'error' | 'aborted'}` (provider in-band failures). Consumers handle both; pick per failure class and document it. - Honor `options.signal` (pass it to fetch / your SDK). -- `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping. +- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e6c0378361..e579d13794 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -8,24 +8,20 @@ A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `exec ## A hook plugin (permission gate) -A hook wraps the `tools/execute` waterfall to veto or rewrite a call — the seam where sandbox, permission, and plan-mode plugins live. +A hook returns a typed decision from the `tools/pre-execute` gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.) ```ts import type { Context } from 'cordis' -import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' declare function isAllowed(exec: ToolExecution): Promise export const name = 'permission-gate' export function apply(ctx: Context) { - ctx.on('tools/execute', async (exec, next) => { + ctx.on('tools/pre-execute', async (exec, next): Promise => { if (!(await isAllowed(exec))) { - return { - callId: exec.callId, - content: [{ type: 'text', text: 'Denied by policy.' }], - isError: true, - } + return { kind: 'deny', reason: 'Denied by policy.' } } return next() }) @@ -34,7 +30,7 @@ export function apply(ctx: Context) { ## A UI plugin -A UI plugin consumes `agent/stream-chunk` and session events for rendering, and drives input back in via `agent.send()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. ```ts import type { Context } from 'cordis' @@ -47,8 +43,10 @@ export const name = 'my-ui' export const inject = ['agents'] export function apply(ctx: Context) { - ctx.on('agent/stream-chunk', (agent, turn, step, chunk) => { - if (chunk.type === 'text-delta') render(chunk.text) + ctx.on('session/event', (_session, event) => { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + render(event.data.chunk.text) + } }) onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) } @@ -56,7 +54,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 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. +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 (settle from the durable `turn/end` session event — the boundary is a session event, not an `agent/*` mirror — with `agent/status` as the fallback if a peer listener starved yours), 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. @@ -83,4 +81,34 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — 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. +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-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. + +## The feature → mechanism map + +Every product feature maps to a listener on a documented extension seam — the microkernel claim made checkable ([microkernel RFC](../rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)). No row modifies the loop. + +| Product feature | Plugin mechanism | +|---|---| +| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | +| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | +| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| Dynamic workflow | orchestrator plugin on `turn/end` (or `step/end`) driving `send`/`steer` + subagents | +| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | +| Context compaction (auto + manual) | the `ctx.compact` seam + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam; auto = token-pressure check before each step; a manual trigger invokes the same `ctx.compact` routine ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| System prompt configurability | `ctx.systemPrompt.section()` with ordering | +| AGENTS.md (root) | a section provider reading the file | +| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | +| Built-in tools | `ctx.tools.register()`; schemas flow into the assembly automatically — the `dsh-tool-*` families (bash, fs, web, subagent, todo) are the shipped examples | +| ToolSearch / progressive disclosure | wrap `agent/request`, filter `req.tools` | +| Tool sandbox (landlock / sandbox-exec) | `tools/pre-execute` (deny), or a sandboxing `BashExecutor` on the `dsh-bash` seam | +| Permission system / AskUserQuestion | `tools/pre-execute` (deny/ask); register an ask tool | +| Plan mode | `tools/pre-execute` (deny writes) + `agent/request` (inject mode prompt) | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model | +| MCP | one plugin per server: discover tools → `ctx.tools.register()` | +| Skills | section + tool registration; `inject()` skill content on invocation | +| Memory | section provider + tool | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | +| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` | +| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | +| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | +| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md new file mode 100644 index 0000000000..4eb7dc482d --- /dev/null +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -0,0 +1,24 @@ +# Responding to review across a stacked PR chain + +A wave of review comments lands across several PRs in a dependent stack (`A ← B ← C …`). This is the discipline for resolving it without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch. + +## Ground rules + +1. **One worktree per PR branch.** Each PR's fixes happen in that PR's own worktree; parallel fixes never share a checkout. +2. **Bring a child up to date by merging the parent down** (`git merge ` into the child, a new merge commit). Never rebase/amend/force-push a pushed branch: rewriting diverges it from what the parent PR and GitHub recorded, breaks the stacked-merge graph, and erases the review-fix history. +3. **A fix lands 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` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. +4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work. + +## Working the wave + +1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause. +2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order. +3. Delegated fixes are trust-but-verify: a sub-agent's report describes intent, 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 red, revert) — a guard that passes both ways guards nothing. A sub-agent that reframes a problem as already-handled is a signal to dig in personally. +4. Reply in the review thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level comment, stating the fix and the commit that carries it. +5. Before merging the stack, check dependents: deleting a PR's base branch auto-closes the dependent PR — check each branch with `gh pr list --state open --base --json number --jq length` (non-zero = open dependents), and merge without `--delete-branch` where a child still bases on the branch. The full landing procedure is the [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill. + +## Verify + +- Every fixed PR shows a new commit (no force-push icon in the PR timeline). +- Each child PR's diff against its parent still shows only its own changes. +- The gates pass on every PR in the stack, not just the top. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md deleted file mode 100644 index 1aac96735b..0000000000 --- a/docs/cordis-catalog/events-and-services.md +++ /dev/null @@ -1,533 +0,0 @@ - - -# 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; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). - -### `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/core/agent/src/types.ts:137`](../../packages/core/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/core/agent/src/types.ts:143`](../../packages/core/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/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) - -#### `agent/pre-step` — serial - -Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. - -Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). - -```ts cordis-catalog -'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/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/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) - -#### `agent/request` — waterfall - -Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-step instead — by the time this fires, `options.messages` is already derived. - -```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/core/agent/src/types.ts:223`](../../packages/core/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/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) - -#### `agent/steering` — emit - -Steering content was injected into a running turn. - -```ts cordis-catalog -'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void -``` - -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/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/core/agent/src/types.ts:180`](../../packages/core/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/core/agent/src/types.ts:229`](../../packages/core/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/core/agent/src/types.ts:175`](../../packages/core/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/core/agent/src/types.ts:243`](../../packages/core/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/core/agent/src/types.ts:236`](../../packages/core/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/core/agent/src/types.ts:169`](../../packages/core/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/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) - -### `llm/*` - -#### `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/llm/src/index.ts:31`](../../packages/llm/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/core/session/src/index.ts:34`](../../packages/core/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/core/session/src/index.ts:40`](../../packages/core/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/core/session/src/index.ts:49`](../../packages/core/session/src/index.ts) - -### `subagent/*` - -#### `subagent/end` — emit - -A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. - -```ts cordis-catalog -'subagent/end'(info: SubagentRunEndInfo): void -``` - -Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts) - -#### `subagent/start` — emit - -A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. - -```ts cordis-catalog -'subagent/start'(info: SubagentRunInfo): void -``` - -Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts) - -### `system-prompt/*` - -#### `system-prompt/assemble` — waterfall - -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/core/system-prompt/src/index.ts:24`](../../packages/core/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/core/system-prompt/src/index.ts:30`](../../packages/core/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/core/tools/src/index.ts:48`](../../packages/core/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/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) - -## Services - -The `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: AgentId, 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) - -### `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: AgentId): Agent | undefined -list(): Agent[] -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/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: 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 -``` - -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:59`](../../packages/bash/bash/src/index.ts) - -### `ctx.compact` — `CompactService` (abstract seam) - -Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. - -Implementations MUST honor: - -- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance). -- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. - -```ts cordis-catalog -abstract compactIfNeeded( agent: CompactAgentContext, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, turn: number, step: number, signal?: AbortSignal, ): Promise -``` - -Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts) - -### `ctx.llm` — `LlmService` - -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 -models(): string[] -stream(options: GenerateOptions): AsyncIterable -``` - -Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) - -Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) - -### `ctx.sessionFork` — `SessionForkService` - -`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive. - -```ts cordis-catalog -snapshot(source: SessionForkSource): SessionForkSeed -fork(options: ForkSessionOptions): Session -``` - -Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/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 -``` - -Types: [SessionEvent](../core-data-structures/core.md) - -Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/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?: SessionId, options?: CreateSessionOptions): Session -prepare(id?: SessionId, options?: CreateSessionOptions): Session -enter(session: Session): () => void -announce(session: Session): void -get(id: SessionId): Session | undefined -list(): Session[] -``` - -Source: [`packages/core/session/src/index.ts:322`](../../packages/core/session/src/index.ts) - -### `ctx.subagents` — `SubagentService` - -The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. - -```ts cordis-catalog -registerProvider(provider: SubagentProvider): () => void -getProvider(name: string): SubagentProvider | undefined -list(): string[] -start(name: string, request: SubagentStartRequest): SubagentRun -``` - -Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) - -### `ctx.systemPrompt` — `SystemPrompt` - -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. - -```ts cordis-catalog -section(section: PromptSection): () => void -tools(provider: () => ToolSchema[]): () => void -assemble(): Promise -``` - -Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/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/core/tools/src/index.ts:277`](../../packages/core/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-bail / 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/cordis-catalog/events.md b/docs/cordis-catalog/events.md new file mode 100644 index 0000000000..d10a7eeabd --- /dev/null +++ b/docs/cordis-catalog/events.md @@ -0,0 +1,354 @@ + + +# Cordis Events Catalog + +Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration's JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. + +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, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. + +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). + +## `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/core/agent/src/types.ts:248`](../../packages/core/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/core/agent/src/types.ts:255`](../../packages/core/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/core/agent/src/types.ts:394`](../../packages/core/agent/src/types.ts) + +### `agent/pre-step` — serial + +Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. + +Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). + +```ts cordis-catalog +'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) + +### `agent/prompt-submit` — waterfall + +Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. + +```ts cordis-catalog +'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise +``` + +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:346`](../../packages/core/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/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) + +### `agent/request` — waterfall + +Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. For surface mutation that must precede history derivation (compaction), use agent/pre-step instead — by the time this fires, `options.messages` is already derived. + +```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/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) + +### `agent/session-start` — emit + +The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup). + +```ts cordis-catalog +'agent/session-start'(agent: Agent, source: SessionStartSource): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/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/core/agent/src/types.ts:264`](../../packages/core/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/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) + +### `agent/turn-continuation` — waterfall + +Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override. + +```ts cordis-catalog +'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) + +## `fs/*` + +### `fs/edit-intent` — waterfall + +Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-fs-policy` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-intent'). + +```ts cordis-catalog +'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts) + +### `fs/observed` — emit + +Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`): the tool does not guard the emit, so a listener that throws surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + +```ts cordis-catalog +'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts) + +### `fs/write-intent` — waterfall + +Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here. + +```ts cordis-catalog +'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts) + +## `llm/*` + +### `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/llm/src/index.ts:33`](../../packages/llm/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/core/session/src/index.ts:36`](../../packages/core/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/core/session/src/index.ts:44`](../../packages/core/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/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts) + +## `subagent/*` + +### `subagent/end` — emit + +A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. + +```ts cordis-catalog +'subagent/end'(info: SubagentRunEndInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts) + +### `subagent/provider-added` — emit + +A provider became resolvable in the SubagentService registry. Consumers that derive state from a named provider (e.g. the model-facing tool wording in `dsh-tool-subagent`) react HERE instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier in cordis.yml" does not mean "registered earlier". + +```ts cordis-catalog +'subagent/provider-added'(provider: SubagentProvider): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) + +### `subagent/provider-removed` — emit + +A provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Consumers holding provider-derived state drop it here; a reload re-fires `subagent/provider-added` with the fresh provider. Delivered with per-listener containment: a throwing subscriber is logged, never starves later subscribers, and never disrupts the provider's teardown. + +```ts cordis-catalog +'subagent/provider-removed'(name: string): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) + +### `subagent/start` — emit + +A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. + +```ts cordis-catalog +'subagent/start'(info: SubagentRunInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/subagent/src/index.ts) + +## `system-prompt/*` + +### `system-prompt/assemble` — waterfall + +Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. + +```ts cordis-catalog +'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise +``` + +Source: [`packages/core/system-prompt/src/index.ts:38`](../../packages/core/system-prompt/src/index.ts) + +### `system-prompt/change` — emit + +A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed). + +```ts cordis-catalog +'system-prompt/change'(): void +``` + +Source: [`packages/core/system-prompt/src/index.ts:44`](../../packages/core/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/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts) + +### `tools/post-execute` — waterfall + +Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. The core tool dispatch sits between the two waterfalls as plain code, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). + +```ts cordis-catalog +'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise +``` + +Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) + +### `tools/pre-execute` — waterfall + +Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` degrades to deny until the permission system lands (`FIXME(permissions)`). + +```ts cordis-catalog +'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +``` + +Types: [ToolExecution](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts) + +## Inherited events (cordis core + loader/hmr/timer) + +The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence. + +- `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)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md new file mode 100644 index 0000000000..ac89ccc970 --- /dev/null +++ b/docs/cordis-catalog/services.md @@ -0,0 +1,255 @@ + + +# Cordis Services Catalog + +Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. + +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 `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. + +## `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: AgentId, 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) + +## `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: AgentId): Agent | undefined +list(): Agent[] +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/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: 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 +``` + +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:59`](../../packages/bash/bash/src/index.ts) + +## `ctx.compact` — `CompactService` (abstract seam) + +Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. + +Implementations MUST honor: + +- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance). +- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery. + +```ts cordis-catalog +abstract compactIfNeeded( agent: CompactAgentContext, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal, ): Promise +abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, turn: number, step: number, signal?: AbortSignal, ): Promise +``` + +Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts) + +## `ctx.fs` — `FileSystem` (abstract seam) + +Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). + +Semantics every backend must honor: + +- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks). +- stat returns FsInfo metadata (never content) or `undefined` when the target is absent. +- readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`. +- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write. +- editText verifies `expected.version` BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`). + +```ts cordis-catalog +abstract resolve(path: string, opts?: { cwd?: string }): Promise +abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract listDir(target: FsTarget, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise +``` + +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) + +## `ctx.llm` — `LlmService` + +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 +models(): string[] +stream(options: GenerateOptions): AsyncIterable +``` + +Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/llm/llm/src/index.ts:78`](../../packages/llm/llm/src/index.ts) + +## `ctx.sessionFork` — `SessionForkService` + +`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive. + +```ts cordis-catalog +snapshot(source: SessionForkSource): SessionForkSeed +fork(options: ForkSessionOptions): Session +``` + +Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/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 +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/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?: SessionId, options?: CreateSessionOptions): Session +prepare(id?: SessionId, options?: CreateSessionOptions): Session +enter(session: Session): () => void +announce(session: Session): void +get(id: SessionId): Session | undefined +list(): Session[] +``` + +Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts) + +## `ctx.subagents` — `SubagentService` + +The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. + +```ts cordis-catalog +registerProvider(provider: SubagentProvider): () => void +getProvider(name: string): SubagentProvider | undefined +list(): string[] +start(name: string, request: SubagentStartRequest): SubagentRun +``` + +Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts) + +## `ctx.systemPrompt` — `SystemPrompt` + +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). + +```ts cordis-catalog +section(section: PromptSection): () => void +tools(provider: () => ToolSchema[]): () => void +variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void +assemble(context: AssembleContext = {}): Promise +``` + +Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts) + +## `ctx.tools` — `ToolRegistry` + +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. 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/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts) + +## `ctx.web` — `WebService` + +The web access service. Registered as `ctx.web` (one instance per context). + +Selection semantics (resolved at execution time, never order-dependent): + +- A configured id that is registered and `status().available` → that provider. +- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. +- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. +- No id configured, exactly one registered usable provider → that provider. +- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. +- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. + +```ts cordis-catalog +registerSearchProvider(provider: WebSearchProvider): () => void +registerFetchProvider(provider: WebFetchProvider): () => void +async search(request: WebSearchRequest, exec?: WebExecContext): Promise +async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +``` + +Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts) + +## Inherited `ctx` members (cordis core + loader/hmr/timer) + +The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence. + +- `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-bail / 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/cordis-primer.md b/docs/cordis-primer.md new file mode 100644 index 0000000000..15534b0ae6 --- /dev/null +++ b/docs/cordis-primer.md @@ -0,0 +1,38 @@ +# Cordis Primer + +Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). + +## Cordis In Five Ideas + +- **A plugin is a unit of behavior.** It can be a function with optional `inject` and `apply(ctx)` fields, or a `Service` subclass whose lifecycle Cordis mounts into the current context. +- **A context is the service container.** A service claims a stable `ctx.` such as `ctx.tools`, `ctx.llm`, or `ctx.sessions`; other plugins program against that key instead of importing a concrete implementation. +- **`inject` is the dependency gate.** A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing. +- **Events are typed extension seams.** Services declare event names through TypeScript declaration merging, then dispatch them as `emit`, `waterfall`, `parallel`, or `serial` depending on whether listeners observe, wrap, fan out, or run in order. +- **Registrations are disposable effects.** Prompt sections, tool schemas, adapters, providers, and listeners are installed through `ctx.effect()` or `ctx.on()` so reload and teardown unwind them predictably. + +## Dispatch Modes + +Use the mode to understand what a listener can do: + +| Mode | Shape | +|---|---| +| `emit` | synchronous notification; listeners observe but do not shape the result | +| `waterfall` | around-middleware; each listener receives `next()` and may wrap, rewrite, or veto | +| `parallel` | awaited fan-out; all listeners run and the dispatcher waits for them | +| `serial` | awaited in registration order; a non-void bail value stops the chain | + +The mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. + +## Cordis Waterfall Semantics + +`ctx.waterfall` is around-middleware, not a reducer. A listener receives `(...args, next)`. Call `next()` to delegate, optionally wrapping the result; return without `next()` to short-circuit. Values propagate through `next()`'s return value. + +Cooperative listeners usually mutate a shared request or decision object and then delegate. Returning a replacement is a takeover: downstream listeners see the replacement, and earlier mutations on the original object do not carry forward. Use `prepend: true` only when the listener must run before ordinary registrations. + +For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate. + +## Practical Rules + +Own vocabulary where the behavior lives: a tool pipeline event belongs to `ctx.tools`, model streaming belongs to `ctx.llm`, and live agent coordination belongs to `ctx.agents`. Prefer events for interception and policy; prefer service methods for direct capability calls. + +Every registration should have a disposer, either by returning one from `ctx.effect()` or using a Cordis helper that does it for you. If teardown order matters, keep the related work in one effect so disposal unwinds in the intended sequence. diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 807c7401fc..273ba5ebe8 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -17,6 +17,24 @@ interface BashExecRequest { timeoutMs?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin, then close it. Absent leaves stdin + * closed/empty (the default for model-driven tool calls). Set by in-process + * plugins (e.g. the hooks bridges, which write a hook command's JSON payload + * to its stdin); the model-facing bash tool does not expose it as a parameter + * (a model that needs stdin uses shell syntax like a heredoc or a pipe). + */ + stdin?: string | undefined + /** + * Extra environment entries for the command, merged AFTER the + * implementation's credential scrub (so an explicit entry here is honored even + * when its name matches the scrub pattern — the caller named a value it holds, + * not the harness's ambient secret). Set by in-process plugins (the hooks + * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing + * bash tool does not expose it as a parameter (a model that needs an env var + * uses shell syntax like `FOO=bar cmd`). + */ + env?: Record | undefined /** * Opaque OWNER token for a background task — the consumer's isolation key * (the tool layer passes the owning agent's `session.header.id`). The @@ -36,6 +54,22 @@ interface BashExecSpec { timeoutMs: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin (then close it), carried through + * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec + * (unlike `owner`): it has no config default, so a missing one means "no + * stdin" — the safe, ordinary case — not a silent footgun, so it stays a + * plain optional rather than required-but-nullable (see the request field). + */ + stdin?: string | undefined + /** + * Extra environment entries, carried through verbatim from + * {@link BashExecRequest.env} and merged by the implementation AFTER its + * credential scrub (an explicit entry wins even when its name matches the + * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no + * config default, absent means "no extra env". + */ + env?: Record | undefined /** * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` * being required on the resolved spec): {@link BashExecutor.resolve} carries @@ -50,6 +84,8 @@ interface BashExecSpec { 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. +`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). + Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. ## Foreground runs: `BashRunResult` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index fbd92f4a78..696143d70f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -11,18 +11,20 @@ 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. +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 `ToolCallView`/`ToolResultView` render-intent 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 | +| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | | [session-fork.md](session-fork.md) | the session fork seam: live-session boundary validation, seed snapshot metadata, and child-session creation | +| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` | > 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. @@ -86,11 +88,10 @@ interface ContentBlockMap { 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`), `ImageBlock` (`url`, `mimeType?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it. A `Message` is a role plus blocks: @@ -107,7 +108,6 @@ Where a message came from is itself a merge-extensible sum type: interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } ``` @@ -131,8 +131,6 @@ interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** @@ -181,7 +179,6 @@ interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record - strict?: boolean } ``` @@ -215,7 +212,7 @@ type SessionEvent = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), 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 thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`), 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 @@ -307,7 +304,42 @@ interface Agent { } ``` -`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 serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. + +## Interception decisions + +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). + +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +interface HookContext { + content: ContentBlock[] + source: MessageSource +} +``` + +`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): + +```ts type-equiv +type PromptDecision = + | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; reason: string } +``` + +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern): + +```ts type-equiv +type ContinuationDecision = + | { action: 'stop' } + | { action: 'continue'; reason?: HookContext } +``` + +`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): + +```ts type-equiv +type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' +``` ## `ToolDefinition` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md new file mode 100644 index 0000000000..bbc55be966 --- /dev/null +++ b/docs/core-data-structures/filesystem.md @@ -0,0 +1,147 @@ +# Filesystem + +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. + +The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. + +Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). + +## Target identity and metadata (provider seam) + +Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. + +```ts type-equiv +interface FsTarget { + targetKey: FsTargetKey + displayPath: string +} +``` + +The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. + +```ts type-equiv +type FsTargetKey = Branded<'FsTargetKey'> +``` + +```ts type-equiv +type FsVersion = Branded<'FsVersion'> +``` + +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. + +```ts type-equiv +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} +``` + +`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. + +```ts type-equiv +interface FsDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: FsTarget + version?: FsVersion + size?: number +} +``` + +## Write and edit guards (provider seam) + +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. + +```ts type-equiv +type FsWriteIntent = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +```ts type-equiv +interface FsWriteOutcome { + operation: 'create' | 'update' + version: FsVersion + before: string | null + after: string +} +``` + +`editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths. + +```ts type-equiv +interface FsEditRequest { + oldString: string + newString: string + replaceAll: boolean +} +``` + +```ts type-equiv +interface FsEditOutcome { + version: FsVersion + before: string + after: string +} +``` + +## The fs policy events (provider-seam vocabulary) + +`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. + +`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated catalog shows the exact signatures on [events.md](../cordis-catalog/events.md). + +## Execution context (policy plugin) + +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. + +```ts type-equiv +interface FsPolicyExec { + agent?: { + session?: object + } +} +``` + +## Read outcome (consumer / read rendering) + +A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. + +```ts type-equiv +interface FileReadOutcome { + offset: number + lines: FileTextLine[] + totalLines: number + truncatedByBytes?: true +} +``` + +## Observed-file state (policy plugin) + +Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). + +## Error taxonomy (provider seam) + +Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. + +```ts type-equiv +type FsErrorCode = + | 'FS_NOT_FOUND' + | 'FS_NOT_DIRECTORY' + | 'FS_NOT_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_PERMISSION_DENIED' + | 'FS_IO_ERROR' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' +``` + +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. + +## The service and the plugin + +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 7439eb14a6..fff329310a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -26,9 +26,22 @@ 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. +- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). 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. +## `AppIdentity` — app attribution + +The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). + +```ts type-equiv +interface AppIdentity { + product: string + version: string + url: string +} +``` + ## `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. @@ -49,7 +62,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()`) 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 § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: @@ -59,7 +72,6 @@ interface ContentBlockMap { 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } ``` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 327162792a..ec5589dd48 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -1,6 +1,6 @@ # 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 **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 event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog/log-events.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). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1a0e209e41..92a9d3421f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -6,7 +6,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t ## `SessionEventMap` — the event vocabulary -The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`. +The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog/log-events.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv interface SessionEventMap { @@ -16,6 +16,17 @@ interface SessionEventMap { 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * A queued prompt an `agent/prompt-submit` listener VETOED — the durable + * record of a blocked prompt and why. Appended in place of the `user/message` + * the prompt would have become, so the block survives replay even in a MIXED + * batch where another queued prompt is allowed (there the turn does not end + * `rejected`, so the boundary reason alone would not preserve it). `content` + * is the original prompt the listener rejected; `reason` is the veto text + * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a + * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history @@ -32,7 +43,7 @@ interface SessionEventMap { */ '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 } } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** @@ -153,7 +164,6 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m ```ts type-equiv interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn @@ -181,6 +191,16 @@ interface TurnEndReasonMap { error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } + /** + * The turn's entire prompt batch was BLOCKED before any step ran — every + * drained queued message was vetoed by an `agent/prompt-submit` listener (a + * hook). The turn still opened (so the boundary stays balanced and the block + * is a durable in-turn fact), but ran zero steps. `reason` carries the block + * message from the vetoing decision. Distinct from `aborted` (a user-driven + * cancel) and `error` (a failure): the prompt was rejected by policy, not + * interrupted or broken. A UI renders it as "prompt blocked by hook". + */ + rejected: { kind: 'rejected'; reason: string } /** * The turn never ended on its own: the process crashed mid-turn and a * persistence backend later closed the orphaned (open) turn on reload so the @@ -195,12 +215,18 @@ interface TurnEndReasonMap { } ``` -`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. +`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` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose whole prompt batch an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `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/architecture/2026-06-15-turn-enclosure-invariant.md). +## Plugin-contributed log-only events + +A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog/log-events.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). + +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.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. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c64d370ff2..926e6ce9b8 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -75,17 +75,18 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. ```ts type-equiv interface SubagentProvider { readonly name: string readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean start(request: SubagentStartRequest): SubagentRun } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events.md)). `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index f255fdbc32..a05ffb3966 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -2,7 +2,7 @@ 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/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/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) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) ## `ToolDefinition` — a registered tool @@ -10,23 +10,25 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona ```ts type-equiv interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise + 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`. + * 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). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) 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 + presentCall?(args: unknown): ToolCallView | 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. + * `result` (`execute`'s content + whether it errored). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) 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 + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined } ``` @@ -71,9 +73,9 @@ type InferArgs = Simplify< `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 +## Execution: the `tools/pre-execute` / `tools/post-execute` pipeline 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`. +`ctx.tools.execute()` runs each call through a two-waterfall pipeline — `tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins gate or transform a call. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`. ```ts type-equiv interface ToolExecution { @@ -98,15 +100,51 @@ interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo + /** + * Extra model-facing context a `tools/post-execute` listener attached for the + * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part + * of this call's `content` — `content`/`feedback` shape the tool RESULT, but + * `additionalContext` is a SEPARATE `context/message`. A step can carry + * multiple tool calls, so the loop BUFFERS every call's `additionalContext` + * and appends them only AFTER all `tool/result`s for the step, keeping + * tool-call/result adjacency intact. Carried on the result purely to ferry it + * from `execute()` up to the loop's per-step buffer. + */ + additionalContext?: HookContext + /** + * The tool-private presentation payload from a successful `execute` (the object + * return form). Threaded onto the `tool/result` session event and back into + * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the + * tool attached none or the call failed. + */ + meta?: unknown } ``` -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. +Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: + +```ts type-equiv +type PreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string } + | { kind: 'ask'; reason?: string } +``` + +```ts type-equiv +type PostToolDecision = + | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } +``` + +Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. 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). +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`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: -> 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. +- `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image — e.g. a file create. A `tool_call_update`'s content REPLACES the call's content, so a mutation tool returns this even when it duplicates the call-time snippet, to keep the result from clobbering the diff with result text). -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). +`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd. + +The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.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/core-data-structures/web.md b/docs/core-data-structures/web.md new file mode 100644 index 0000000000..05c3f648c0 --- /dev/null +++ b/docs/core-data-structures/web.md @@ -0,0 +1,94 @@ +# Web Access + +The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL. + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) + +## Why one seam for two capabilities + +Search and fetch share no request schema and no business logic, but they are deliberately one `ctx.web` middle layer: one provider-selection policy owner, one abort/error vocabulary, one product-facing "how this harness reaches the web" config surface. The cost is the parallel `searchX`/`fetchX` method pairs on the service; that parallelism is intentional, not a missed extraction. Providers register **capabilities** (a `WebSearchProvider` or `WebFetchProvider`), not tools; the model-facing names, schemas, prompt guidance, and presentation all live in the single `dsh-tool-web` consumer. + +## Search request and result + +The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. + +```ts type-equiv +interface WebSearchRequest { + readonly query: string + /** + * Upper bound on returned sources; the seam truncates to it. Omitted = no + * bound. `dsh-tool-web` always sets it. + */ + readonly maxResults?: number +} +``` + +```ts type-equiv +interface WebSearchResult { + readonly providerId: string + readonly query: string + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} +``` + +`content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. + +```ts type-equiv +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +## Fetch request and result + +```ts type-equiv +interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} +``` + +HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource. + +```ts type-equiv +interface WebFetchResult { + readonly providerId: string + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} +``` + +`WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`). + +```ts type-equiv +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +## Provider status + +A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message. + +```ts type-equiv +type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } +``` + +Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. + +## Errors + +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by `WebService` selection and the shared contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. + +## The service + +`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md new file mode 100644 index 0000000000..cf30072094 --- /dev/null +++ b/docs/defensive-patterns.md @@ -0,0 +1,27 @@ +# Defensive patterns + +Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](testing.md). + +## Report orthogonal outcomes independently + +A result can be several things at once — a process can time out AND exit 0 because it trapped the signal. Surface each independent fact (`timedOut`, `signal`, `exitCode`) on its own; never nest one flag's report inside another's branch, or a caller reads a cut-short run as a clean success. + +## Honor cross-seam contracts on BOTH sides + +When an interface documents two valid ways to signal something — an adapter may report failure by THROWING from `stream()` or by ending the stream with a `finish {kind:'error'|'aborted'}` chunk — the consumer handles both, not just the one the first implementation used. A library-backed adapter that can't throw mid-stream relies on the in-band path; a loop that only catches throws turns a provider 401 into a normal completed turn. Document the contract where the type is defined; exercise every branch through the real consumer. + +## Async state is not synchronous state + +`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns (the loop batches queued messages). The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. + +## Dispose must reach quiescence, not just request it + +A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. Tests prove disposal waited (pid gone right after `await fiber.dispose()`), not merely that the process eventually dies. + +## Contain callback exceptions at the boundary + +A user-supplied listener 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; one bad subscriber never breaks core lifecycle. + +## Never hand untrusted output the ambient environment or predictable paths + +Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml new file mode 100644 index 0000000000..cef11b8352 --- /dev/null +++ b/docs/development.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +development.md: f032764fff29baaca007211db8b69d9a5129078f +development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9 diff --git a/docs/development.md b/docs/development.md index 431d7b4dac..f032764fff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Development guide +English | [中文](development.zh.md) + This guide covers the local setup needed to work on DeepSeek Harness and understand the local hooks, daily checks, and CI gates. ## Prerequisites @@ -7,7 +9,7 @@ This guide covers the local setup needed to work on DeepSeek Harness and underst - Node.js 24 or newer. The repo declares `node >=24`; CI runs the matrix on Node 24 and 26. - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git. -- Optional: a DeepSeek API key for the coding-agent demo and real-API e2e tests. +- Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests. ## First-time setup @@ -43,7 +45,7 @@ pnpm run build ## Environment variables -The real DeepSeek adapter and coding-agent demo read credentials from the environment or from a gitignored `.env` at the repo root: +The real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root: ```sh DEEPSEEK_API_KEY=sk-... @@ -94,11 +96,16 @@ pnpm run typecheck # build package/vendor outputs, then typecheck examples, pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs -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 gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source +pnpm run verify-cordis-catalog # fail if either cordis catalog is stale +pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions +pnpm run verify-doc-graphs # fail if generated relationship docs are stale +pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown +pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type -pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap/link, and type-equiv verification +pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling +pnpm run doc-sync # all Markdown/doc gates; see the doc-sync script in package.json for the full list 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 # emit lib/types intermediates, then bundle lib/index.* runtime files @@ -106,7 +113,7 @@ pnpm run verify-node-next-types # fail if built declarations are not NodeNext-c pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check ``` -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, cordis events/services catalog drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. +When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review. ## Demos @@ -116,13 +123,13 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The coding-agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The REPL agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:coding +pnpm run demo:repl ``` -The ACP server demo exposes the same coding agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: +The ACP server agent demo exposes the agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: ```sh pnpm run demo:acp diff --git a/docs/development.zh.md b/docs/development.zh.md new file mode 100644 index 0000000000..3a650d03ce --- /dev/null +++ b/docs/development.zh.md @@ -0,0 +1,160 @@ +# 开发指南 + +[English](development.md) | 中文 + +本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁。 + +## 前置条件 + +- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵。 +- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。 +- Git。 +- 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。 + +## 首次搭建 + +在仓库根目录安装依赖: + +```sh +pnpm install +``` + +安装同时会运行根目录的 `postinstall` 脚本,它通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook;该包装脚本使用 lefthook 经过评审的 `--force` 模式,使已存在 `core.hooksPath` 的关联 worktree 不会让正常的 `pnpm run …` 命令失败。 + +如果因为依赖是从缓存恢复或 `postinstall` 被跳过而缺少钩子,手动安装: + +```sh +pnpm exec lefthook install --force +``` + +新克隆后先跑一次类型检查: + +```sh +pnpm run typecheck +``` + +这次首跑会构建 package/vendor 构建图,并跑根目录 no-emit `tsconfig.json` 图(覆盖 examples、tests 和 scripts)。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 + +如果准备从新克隆或新 worktree 推送,还要构建一次: + +```sh +pnpm run build +``` + +`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件。 + +## 环境变量 + +真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证: + +```sh +DEEPSEEK_API_KEY=sk-... +DEEPSEEK_BASE_URL=https://... # optional +``` + +`DEEPSEEK_BASE_URL` 可选,默认为公开 API。绝不要提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 + +## Git 钩子 + +lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: + +- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。 +- `pre-push` 运行 `pnpm run test`、`pnpm run test:snapshot`、`pnpm run hygiene`、`pnpm run doc-sync` 和 `pnpm run verify-module-graph`。 + +vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。 + +这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 24 和 26 上跑矩阵。 + +## CI 门禁 + +GitHub 工作流在每个 pull request 上运行这些门禁: + +- `pnpm install --frozen-lockfile` +- `pnpm run constraints` +- `pnpm run typecheck` +- `pnpm run lint` +- `pnpm run doc-sync` +- `pnpm run verify-module-graph` +- `pnpm run test:coverage` +- `pnpm run test:snapshot` +- `pnpm run build` +- `pnpm run hygiene` +- 一个 echo-agent 冒烟测试,检查演示的工具调用、工具结果和 JSONL 输出 +- built-bin 冒烟测试,用纯 `node` 运行发布产物 `lib/bin.js` 入口 + +`pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。 + +## 日常命令 + +在仓库根目录使用: + +```sh +pnpm run test # unit tests +pnpm run test:coverage # unit tests with per-file coverage gates +pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY +pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run lint # eslint . +pnpm run lint:fix # eslint . --fix +pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source +pnpm run verify-cordis-catalog # fail if either cordis catalog is stale +pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions +pnpm run verify-doc-graphs # fail if generated relationship docs are stale +pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree +pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown +pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax +pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type +pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling +pnpm run doc-sync # all Markdown/doc gates; see the doc-sync script in package.json for the full list +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 # emit lib/types intermediates, then bundle lib/index.* runtime files +pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable +pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check +``` + +改动 package 的公开行为时,在同一个变更里更新相关 README 或 JSDoc。`pnpm run doc-sync` 能抓住被检查的 TypeScript 片段、生成文档新鲜度、markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。 + +## 演示 + +echo 演示不需要 API 凭证: + +```sh +pnpm run demo:echo +``` + +REPL agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: + +```sh +pnpm run demo:repl +``` + +ACP 服务器 agent 演示通过 JSON-RPC stdio 暴露 agent,同样需要 `DEEPSEEK_API_KEY`: + +```sh +pnpm run demo:acp +``` + +## TODO 标记 + +用三种注释标签之一标记代码中的已知问题,按紧急程度排序: + +- `FIXME`——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。 +- `TODO`——应当尽快修复的问题,等资源到位就处理。 +- `XXX`——也许某天会修的问题;优先级最低,不作承诺。 + +选择与紧急程度匹配的标签,让扫代码的人一眼分清「发布阻塞」和「有空再说」。 + +## 逐字记录类型(`ts type-equiv`) + +[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,把它围栏成 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: + +```json +{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } +``` + +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型,门禁会失败直到你更新粘贴内容;当你增删一个块,在同一个变更里更新 manifest。 + +## 架构上下文 + +改动 `packages/` 下的任何东西之前先读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam(扩展点)与显式扩展点构建。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md new file mode 100644 index 0000000000..18c2b9faf0 --- /dev/null +++ b/docs/event-producer-consumer.md @@ -0,0 +1,38 @@ + + +# Event Producer And Consumer Matrix + +This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment. + +| Event | Mode | Declared in | Dispatchers | Listeners | +| --- | --- | --- | --- | --- | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`), [`compact-basic`](../packages/compact/compact-basic) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:33`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:36`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:44`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:87`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:66`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | + +Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md new file mode 100644 index 0000000000..92953d9fe7 --- /dev/null +++ b/docs/graph-atlas.md @@ -0,0 +1,25 @@ + + +# Documentation Graph Index + +These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog/](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md). + +The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md). + +| Graph | Mode | +| --- | --- | +| [module dependency graph](module-graph.md) | `generated` | +| [tool schema catalog and package map](tool-catalog/tools.md) | `generated` | +| [capability seams and core services](capability-seams.md) | `hybrid generated` | +| [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | +| [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` | +| [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | +| [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | +| [agent turn and step lifecycle](agent-lifecycle.md) | `curated` | +| [tool execution pipeline](tool-execution-pipeline.md) | `curated` | +| [ACP snapshot replay](../packages/ui/acp/snapshot-replay.md) | `curated` | + +Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`. + +Maintenance mode: mixed: each linked page declares generated, hybrid, or curated mode. diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml new file mode 100644 index 0000000000..7a2407ed36 --- /dev/null +++ b/docs/i18n/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6e2bbd27c3288037bafeb6cc71b801d56b956ab4 +README.zh.md: 04c99ae336cf1e96cbc185f0ccbd063ef8977944 diff --git a/docs/i18n/README.md b/docs/i18n/README.md new file mode 100644 index 0000000000..6e2bbd27c3 --- /dev/null +++ b/docs/i18n/README.md @@ -0,0 +1,50 @@ +# Bilingual documentation + +English | [中文](README.zh.md) + +This repo's documentation is read by people and agents both inside and outside the company, so the README and the docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). + +## The pairing contract + +- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first RFC is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing. +- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files. +- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing: + + ```yaml + foo.md: 3f786850e387550fdab836ed7e6dc881de23001b + foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b + ``` + + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p `), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency. +- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. +- **Structure mirrors the counterpart.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). + +## The gate: verify-translation-pairing + +`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: + +1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. +2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher. +3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. + +`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports. + +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. + +The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. + +## Scope, exclusions, and rollout + +**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. + +**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): + +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. +- `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. +- `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction. + +**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Pairs land in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any pair that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. + +## Division of labor + +Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate exists so that neither the agent nor the reviewer has to remember the contract: pair completeness, consistency, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md new file mode 100644 index 0000000000..04c99ae336 --- /dev/null +++ b/docs/i18n/README.zh.md @@ -0,0 +1,50 @@ +# 双语文档 + +[English](README.md) | 中文 + +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。进仓的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 + +## 配对契约 + +- **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 RFC 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。 +- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对整体合入:PR 永远不会只带一种语言而缺其余两个文件。 +- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash: + + ```yaml + foo.md: 3f786850e387550fdab836ed7e6dc881de23001b + foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b + ``` + + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。 +- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 +- **结构与另一侧一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 + +## 门禁:verify-translation-pairing + +`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约: + +1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 +2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。 +3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 + +`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态——missing、out-of-sync 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 + +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码/README doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 + +把门禁的边界说白:**门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。**它检查 hash 和形状;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 + +## 范围、排除与推进 + +**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 + +**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): + +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 +- `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/i18n/terminology.md`——术语表本身即是双语构造。 + +**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 + +## 分工 + +这里的对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对完整性、一致性和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f9baa40adc..f95a8e3a8f 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -7,6 +7,7 @@ | ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | +| CI | CI | | | CLI | CLI | 首次出现可写:命令行界面(CLI) | | Cordis | Cordis | 保留英文 | | Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) | @@ -17,16 +18,22 @@ | loader | loader | | | LLM | LLM | 首次出现可写:大语言模型(LLM) | | MCP | MCP | | +| PR | PR | 首次出现可写:PR(pull request) | | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | +| backlog | backlog | 双语翻译语境指待翻清单 | +| blob hash | blob hash | git 对象哈希;`git hash-object` 的结果 | +| doc-sync | doc-sync | 仓库门禁名,保留英文 | +| e2e | e2e | | | fiber | fiber | 首次出现可写:fiber(插件运行时) | | fixture | fixture | 指测试前置数据或环境 | | fork | fork | 保留英文 | | harness | harness | 保留英文 | | manifest | manifest | 描述模块或工具元数据的文件 | +| monorepo | monorepo | | | schema DSL | schema DSL | | | schema | schema | 保留英文 | | seam | seam | 首次出现可写:seam(扩展点) | @@ -36,6 +43,7 @@ | subagent | subagent | 首次出现可写:subagent(子 agent) | | transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | | waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) | +| worktree | worktree | git 工作区概念,保留英文 | | wire format | 协议格式 | 首次出现可写:协议格式(wire format) | | adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) | | adapter | 适配器 | | @@ -54,28 +62,39 @@ | config | 配置 | | | context | 上下文 | | | context compaction | 上下文压缩 | 首次出现可写:上下文压缩(context compaction) | +| contract | 契约 | 如:配对契约(pairing contract);另见 adapter contract | | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | | dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | | durability | 持久性 | | +| enforcement frontier | 强制边界 | i18n 机制词:manifest `required` 清单所划的门禁生效范围 | | event log | 事件日志 | | | event | 事件 | | | event stream | 事件流 | | +| event-sourced | 事件溯源 | DDD 社区通行译法 | | executor | 执行器 | | | extension | 扩展 | | +| fail-fast | 快速失败 | | +| fenced code block | 围栏代码块 | MDN 中文同译 | | finish reason | 结束原因 | | +| fingerprint | 指纹 | i18n 机制词:`.zh.md` 首行记录英文源 blob hash 的 `i18n-source` 注释 | | foreground run | 前台运行 | | +| freshness | 新鲜度 | MDN HTTP 缓存中文同译(freshness lifetime → 新鲜度生命周期);指译文相对英文源的同步状态 | | hook | 钩子 | | | implementation | 实现 | | | inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 | +| info string | 信息字符串 | CommonMark 中文同译;代码围栏 ``` 之后的语言标注 | | injection | 注入 | | | interface | 接口 | | | integration | 集成 | | +| language switcher | 语言切换行 | i18n 机制词:双语配对文件顶部的互链行 | | memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | | mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | +| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 | +| pairing | 配对 | | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | @@ -93,11 +112,15 @@ | service | 服务 | | | session | 会话 | | | session event | 会话事件 | | +| smoke test | 冒烟测试 | | | snapshot | 快照 | | | spine | 主干 | | +| staged | 暂存 | git 官方中文同译 | +| stale | 陈旧 | MDN HTTP 缓存中文同译,与「新鲜(fresh)」成对;门禁输出保留英文 `stale`;expired 才译「过期」 | | step | 步骤 | | | stream | 流 | | | streaming | 流式输出 | | +| structural signature | 结构签名 | i18n 机制词:配对门禁比对的有序结构序列 | | system prompt | 系统提示词 | | | taxonomy | 分类体系 | | | token usage | token 用量 | | diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml new file mode 100644 index 0000000000..579bd51511 --- /dev/null +++ b/docs/i18n/translation-rules.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +translation-rules.md: 4e190f58469f7d402dfa5600f17cf1621484f138 +translation-rules.zh.md: 89a1cddd23126f24354ce1f8d9af4e7bd403454d diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md new file mode 100644 index 0000000000..4e190f5846 --- /dev/null +++ b/docs/i18n/translation-rules.md @@ -0,0 +1,60 @@ +# Translation rules + +English | [中文](translation-rules.zh.md) + +How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. + +## Faithfulness + +- The counterpart MUST say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change. +- The counterpart SHOULD read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse. +- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom. + +## Structure preservation + +The paired files MUST match one to one in: + +- heading hierarchy (same levels, same order — heading TEXT is translated), +- list shape and numbering, +- tables (same columns, same row order; header cells translated per terminology), +- fenced code blocks — **byte-identical, including comments**; code is part of the verified surface (` ```ts ` blocks compile under `doc-typecheck`), and an edited comment is drift the fence-count gate cannot see, +- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted, +- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. + +The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline. + +## Terminology + +- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. When the Chinese side is authored first, the English counterpart uses the table's English column the same way. +- A technical term NOT in the table MAY be translated only when a major Chinese-language OSS or vendor doc has an established rendering for it (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs). Cite the precedent in the PR. +- A term with NO established precedent MUST stay in English in the translation and MUST be listed in the PR description under 「待定术语」(pending terms) with a suggested rendering for the reviewer to decide. MUST NOT invent a Chinese rendering inline — an unprecedented translation creates exactly the ambiguity the terminology table exists to prevent. Decided terms then land in [terminology.md](terminology.md) in the same PR or a follow-up. + +## Typography + +These rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011: + +- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything. +- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`). +- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas. +- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always. +- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code. +- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice). +- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration. + +## Quality bar + +- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. +- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. +- The mechanical contract (consistency record, switcher, structure, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers. + +## References + +Authorities cited by these rules, for humans and agents who want the underlying reasoning: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation. +- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice. +- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team. +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone. +- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides. +- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines. +- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize. diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md new file mode 100644 index 0000000000..89a1cddd23 --- /dev/null +++ b/docs/i18n/translation-rules.zh.md @@ -0,0 +1,60 @@ +# 翻译规则 + +[English](translation-rules.md) | 中文 + +本文规定如何在本仓库文档配对的两侧之间进行翻译。两种语言同权(见 [README.md](README.md)):一次变更用任一语言撰写,那一侧就是这次更新的源——本文的规则约束的是产出或更新另一侧。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**自行裁量。 + +## 忠实性 + +- 另一侧必须说撰写侧所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜:改正错的那一侧,并在同一个变更里把另一侧带上。 +- 另一侧应当读起来是其语言自然的技术文字,而不是逐词对照。翻译语义,在目标语言语法需要处重组句子,并保持原作者的语域——简练的保持简练。 +- 不要翻译不可译的东西:一句话如果依赖源语言的习语而无法自然转换,就翻译它的意思,而不是习语本身。 + +## 结构保持 + +配对的两个文件必须在以下方面一一对应: + +- 标题层级(相同级别、相同顺序——标题的**文字**要翻译), +- 列表形态与编号, +- 表格(相同的列、相同的行序;表头单元格按术语表翻译), +- 围栏代码块——**逐字节一致,包括注释**;代码属于受验证的范围(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, +- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, +- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标——按约定是 `.md` 路径而非 `.zh.md` 兄弟文件——这样某对文档先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 + +本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 + +## 术语 + +- [terminology.md](terminology.md) 是双向的术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。中文先行撰写时,英文另一侧同样按表中英文列使用术语。 +- 表中**没有**的技术术语,只有当某个主要中文 OSS 或厂商文档已有成型译法时(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档)才可以翻译。在 PR 中注明先例出处。 +- **没有**成型先例的术语,译文中必须保留英文,并且必须在 PR 描述的「待定术语」下列出、附上建议译法交评审者定夺。禁止就地发明中文译法——无先例的翻译恰恰制造了术语表要防止的歧义。定下来的术语随后在同一个 PR 或后续 PR 进入 [terminology.md](terminology.md)。 + +## 排版 + +本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011: + +- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。 +- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。 +- 并列顿开:中文的并列项之间用顿号(、),不用逗号。 +- 禁止使用全角数字或全角拉丁字母——永远不写 `123`,永远写 `123`。 +- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek——除非引用代码,否则绝不写 `github`/`Github`。 +- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。 +- 强调标记(`**加粗**`、`*斜体*`)落在与另一侧相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 + +## 质量线 + +- 一对文档的完成标准:一位双语工程师只读其中任一文件,得到与另一文件读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 +- 交付前,对照本文自查一遍,并**只读另一侧**再通读一遍、不看源侧对照;没有源文锚着,别扭的表述更容易被听出来。 +- 机械契约(一致性记录、切换行、结构、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 + +## 参考资料 + +本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)——中西文混排空格与标点的社区事实标准。 +- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)——与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。 +- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)——最大的中文本地化团队的术语首现与标点实践。 +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)——逐术语的译/留决策与语气。 +- [zh-style-guide](https://zh-style-guide.readthedocs.io)——社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。 +- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides)——排版学与厂商本地化的正式基线。 +- GB/T 19682-2005《翻译服务译文质量要求》——国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 diff --git a/docs/module-graph.md b/docs/module-graph.md index cd8f4cb246..561d087d1a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -3,135 +3,254 @@ # Module dependency graph -Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package's `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped. +Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package's `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages//` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped. ```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 - compact --> llm - compact --> session - llm-replay --> llm - llm-replay --> session - session-fork --> session - session-persistence --> session - compact-basic --> agent - compact-basic --> compact - compact-basic --> llm - compact-basic --> session - invariants --> agent - invariants --> llm - invariants --> session - session-persistence-jsonl --> session - session-persistence-jsonl --> session-persistence - session-persistence-sqlite --> session - session-persistence-sqlite --> session-persistence - tools --> agent - tools --> llm - tools --> system-prompt - ui-stdio --> agent - ui-stdio --> llm - ui-stdio --> session - acp --> agent - acp --> llm - acp --> session - acp --> session-persistence - acp --> tools - agent-loop --> agent - agent-loop --> llm - agent-loop --> session - agent-loop --> session-persistence - agent-loop --> system-prompt - agent-loop --> tools - subagent --> agent - subagent --> llm - subagent --> tools - tool-bash --> agent - tool-bash --> bash - tool-bash --> llm - tool-bash --> tools - tool-todo --> agent - tool-todo --> session - tool-todo --> 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 - subagent-acp --> agent - subagent-acp --> llm - subagent-acp --> subagent - subagent-inprocess --> agent - subagent-inprocess --> llm - subagent-inprocess --> session - subagent-inprocess --> subagent - subagent-mock --> agent - subagent-mock --> llm - subagent-mock --> subagent - tool-subagent --> agent - tool-subagent --> llm - tool-subagent --> subagent - tool-subagent --> tools - acp-agent --> acp - acp-agent --> agent-core - acp-agent --> session-persistence-jsonl - stdio-agent --> agent - stdio-agent --> agent-core - stdio-agent --> session - stdio-agent --> session-persistence-jsonl - stdio-agent --> ui-stdio - subagent-fork --> agent - subagent-fork --> session - subagent-fork --> subagent - subagent-fork --> subagent-inprocess - subagent-spawn --> subagent - subagent-spawn --> subagent-inprocess +flowchart TD + subgraph group_util["packages/util"] + pkg_brand["brand"] + end + subgraph group_llm["packages/llm"] + pkg_llm["llm"] + pkg_llm_deepseek["llm-deepseek"] + pkg_llm_pi_ai["llm-pi-ai"] + end + subgraph group_core["packages/core"] + pkg_agent["agent"] + pkg_agent_core["agent-core"] + pkg_agent_loop["agent-loop"] + pkg_session["session"] + pkg_system_prompt["system-prompt"] + pkg_tools["tools"] + end + subgraph group_bash["packages/bash"] + pkg_bash["bash"] + pkg_bash_local["bash-local"] + pkg_tool_bash["tool-bash"] + end + subgraph group_fs["packages/fs"] + pkg_fs["fs"] + pkg_fs_local["fs-local"] + pkg_fs_policy["fs-policy"] + pkg_tool_fs["tool-fs"] + end + subgraph group_compact["packages/compact"] + pkg_compact["compact"] + pkg_compact_basic["compact-basic"] + end + subgraph group_subagent["packages/subagent"] + pkg_subagent["subagent"] + pkg_subagent_acp["subagent-acp"] + pkg_subagent_fork["subagent-fork"] + pkg_subagent_inprocess["subagent-inprocess"] + pkg_subagent_spawn["subagent-spawn"] + pkg_tool_subagent["tool-subagent"] + end + subgraph group_web["packages/web"] + pkg_tool_web["tool-web"] + pkg_web["web"] + pkg_web_fetch_local["web-fetch-local"] + pkg_web_search_deepseek["web-search-deepseek"] + pkg_web_search_exa["web-search-exa"] + pkg_web_search_perplexity["web-search-perplexity"] + end + subgraph group_todo["packages/todo"] + pkg_tool_todo["tool-todo"] + end + subgraph group_hooks["packages/hooks"] + pkg_hook_protocol["hook-protocol"] + pkg_hooks_claude["hooks-claude"] + pkg_hooks_codex["hooks-codex"] + end + subgraph group_session_persistence["packages/session-persistence"] + pkg_session_persistence["session-persistence"] + pkg_session_persistence_jsonl["session-persistence-jsonl"] + pkg_session_persistence_sqlite["session-persistence-sqlite"] + end + subgraph group_support["packages/support"] + pkg_invariants["invariants"] + pkg_llm_replay["llm-replay"] + pkg_subagent_mock["subagent-mock"] + end + subgraph group_ui["packages/ui"] + pkg_acp["acp"] + pkg_acp_agent["acp-agent"] + pkg_app_boot["app-boot"] + pkg_stdio_agent["stdio-agent"] + end + subgraph group_session_fork["packages/session-fork"] + pkg_session_fork["session-fork"] + end + pkg_llm --> pkg_brand + pkg_bash --> pkg_brand + pkg_llm_deepseek --> pkg_llm + pkg_llm_pi_ai --> pkg_llm + pkg_session --> pkg_brand + pkg_session --> pkg_llm + pkg_system_prompt --> pkg_llm + pkg_bash_local --> pkg_bash + pkg_fs --> pkg_brand + pkg_fs --> pkg_llm + pkg_web --> pkg_llm + pkg_agent --> pkg_brand + pkg_agent --> pkg_llm + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt + pkg_fs_local --> pkg_fs + pkg_fs_policy --> pkg_fs + pkg_compact --> pkg_llm + pkg_compact --> pkg_session + pkg_web_fetch_local --> pkg_web + pkg_web_search_deepseek --> pkg_web + pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_web + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_session + pkg_session_persistence --> pkg_session + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session + pkg_session_fork --> pkg_session + pkg_tools --> pkg_agent + pkg_tools --> pkg_llm + pkg_tools --> pkg_system_prompt + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_session_persistence_jsonl --> pkg_session + pkg_session_persistence_jsonl --> pkg_session_persistence + pkg_session_persistence_sqlite --> pkg_session + pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_invariants --> pkg_agent + pkg_invariants --> pkg_llm + pkg_invariants --> pkg_session + pkg_agent_loop --> pkg_agent + pkg_agent_loop --> pkg_llm + pkg_agent_loop --> pkg_session + pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_system_prompt + pkg_agent_loop --> pkg_tools + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tools + pkg_tool_fs --> pkg_fs + pkg_tool_fs --> pkg_llm + pkg_tool_fs --> pkg_session + pkg_tool_fs --> pkg_system_prompt + pkg_tool_fs --> pkg_tools + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_tools + pkg_tool_web --> pkg_llm + pkg_tool_web --> pkg_system_prompt + pkg_tool_web --> pkg_tools + pkg_tool_web --> pkg_web + pkg_tool_todo --> pkg_agent + pkg_tool_todo --> pkg_session + pkg_tool_todo --> pkg_tools + pkg_hooks_codex --> pkg_agent + pkg_hooks_codex --> pkg_hook_protocol + pkg_hooks_codex --> pkg_llm + pkg_hooks_codex --> pkg_session + pkg_hooks_codex --> pkg_tools + pkg_acp --> pkg_agent + pkg_acp --> pkg_llm + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_tools + pkg_agent_core --> pkg_agent + pkg_agent_core --> pkg_agent_loop + pkg_agent_core --> pkg_invariants + pkg_agent_core --> pkg_llm + pkg_agent_core --> pkg_session + pkg_agent_core --> pkg_system_prompt + pkg_agent_core --> pkg_tool_bash + pkg_agent_core --> pkg_tools + pkg_subagent_acp --> pkg_agent + pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_subagent + pkg_subagent_inprocess --> pkg_agent + pkg_subagent_inprocess --> pkg_llm + pkg_subagent_inprocess --> pkg_session + pkg_subagent_inprocess --> pkg_subagent + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_tools + pkg_hooks_claude --> pkg_agent + pkg_hooks_claude --> pkg_hook_protocol + pkg_hooks_claude --> pkg_llm + pkg_hooks_claude --> pkg_session + pkg_hooks_claude --> pkg_subagent + pkg_hooks_claude --> pkg_tools + pkg_subagent_mock --> pkg_agent + pkg_subagent_mock --> pkg_llm + pkg_subagent_mock --> pkg_subagent + pkg_subagent_fork --> pkg_agent + pkg_subagent_fork --> pkg_session + pkg_subagent_fork --> pkg_subagent + pkg_subagent_fork --> pkg_subagent_inprocess + pkg_subagent_spawn --> pkg_subagent + pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_acp_agent --> pkg_acp + pkg_acp_agent --> pkg_agent_core + pkg_acp_agent --> pkg_app_boot + pkg_acp_agent --> pkg_session_persistence_jsonl + pkg_stdio_agent --> pkg_agent + pkg_stdio_agent --> pkg_agent_core + pkg_stdio_agent --> pkg_app_boot + pkg_stdio_agent --> pkg_llm + pkg_stdio_agent --> pkg_session + pkg_stdio_agent --> pkg_session_persistence_jsonl ``` -| Package | Depends on | -| --- | --- | -| `brand` | — | -| `bash` | `brand` | -| `llm` | `brand` | -| `bash-local` | `bash` | -| `llm-deepseek` | `llm` | -| `llm-pi-ai` | `llm` | -| `session` | `brand`, `llm` | -| `system-prompt` | `llm` | -| `agent` | `brand`, `llm`, `session` | -| `compact` | `llm`, `session` | -| `llm-replay` | `llm`, `session` | -| `session-fork` | `session` | -| `session-persistence` | `session` | -| `compact-basic` | `agent`, `compact`, `llm`, `session` | -| `invariants` | `agent`, `llm`, `session` | -| `session-persistence-jsonl` | `session`, `session-persistence` | -| `session-persistence-sqlite` | `session`, `session-persistence` | -| `tools` | `agent`, `llm`, `system-prompt` | -| `ui-stdio` | `agent`, `llm`, `session` | -| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | -| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | -| `subagent` | `agent`, `llm`, `tools` | -| `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-todo` | `agent`, `session`, `tools` | -| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | -| `subagent-acp` | `agent`, `llm`, `subagent` | -| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | -| `subagent-mock` | `agent`, `llm`, `subagent` | -| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | -| `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | -| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | -| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | -| `subagent-spawn` | `subagent`, `subagent-inprocess` | +| Package | Group | Depends on | +| --- | --- | --- | +| [`brand`](../packages/util/brand) | `util` | — | +| [`app-boot`](../packages/ui/app-boot) | `ui` | — | +| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | +| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | +| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-fork`](../packages/session-fork/session-fork) | `session-fork` | [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | diff --git a/docs/persistence-catalog/log-events.md b/docs/persistence-catalog/log-events.md new file mode 100644 index 0000000000..bce865dedd --- /dev/null +++ b/docs/persistence-catalog/log-events.md @@ -0,0 +1,240 @@ + + +# Persistence Log Event Catalog + +Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](../core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](../core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](../cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). + +This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](../rfc/implemented/process/2026-07-04-persistence-log-catalog.md). + +The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](../core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](../core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. + +## Events + +### `assistant/*` + +#### `assistant/chunk` — log-only + +Raw stream chunk — token-level replay fidelity. + +```ts persistence-catalog +'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } +``` + +Types: [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/core/session/src/types.ts:237`](../../packages/core/session/src/types.ts) + +#### `assistant/message` — surface + +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. + +```ts persistence-catalog +'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [TokenUsage](../core-data-structures/llm-streaming.md) + +Source: [`packages/core/session/src/types.ts:244`](../../packages/core/session/src/types.ts) + +### `compact/*` + +#### `compact/end` — log-only + +Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. + +```ts persistence-catalog +'compact/end': { turn: number; error?: string } +``` + +Source: [`packages/compact/compact/src/types.ts:37`](../../packages/compact/compact/src/types.ts) + +#### `compact/start` — log-only + +Marks the start of a compaction — log-only, holds the lock until `compact/end`. + +```ts persistence-catalog +'compact/start': { turn: number } +``` + +Source: [`packages/compact/compact/src/types.ts:23`](../../packages/compact/compact/src/types.ts) + +#### `compact/summary` — log-only + +Provenance record of a completed summarization — log-only, no surfaceOp. The summary content is in `data.summary`; the actual surface replacement is performed by a subsequent `user/message` event that shadows the compacted range. + +```ts persistence-catalog +'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number } +``` + +Types: [ContentBlock](../core-data-structures/core.md) + +Source: [`packages/compact/compact/src/types.ts:30`](../../packages/compact/compact/src/types.ts) + +### `context/*` + +#### `context/message` — surface + +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. + +```ts persistence-catalog +'context/message': { content: ContentBlock[]; source: MessageSource } +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:235`](../../packages/core/session/src/types.ts) + +### `hook/*` + +#### `hook/invoked` — log-only + +A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside. + +```ts persistence-catalog +'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string } +``` + +Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../../packages/hooks/hook-protocol/src/types.ts) + +#### `hook/result` — log-only + +A hook command's outcome — log-only, paired with a prior `hook/invoked` (same `handlerId`). `decision` is the dialect-neutral outcome derived by `appendHookResult` (which owns the rule): the hook's parsed decision (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to halt via `continue:false`, else `'pass'`. `exitCode` is the process exit (absent if it never ran), `stderrSummary` the trimmed stderr truncated to the bridge's configured cap (the block reason source on exit 2), `durationMs` the wall-clock runtime (audit timing; snapshot replay normalizes it). `turn` matches the `hook/invoked`. + +```ts persistence-catalog +'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number } +``` + +Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../../packages/hooks/hook-protocol/src/types.ts) + +### `prompt/*` + +#### `prompt/blocked` — log-only + +A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. Appended in place of the `user/message` the prompt would have become, so the block survives replay even in a MIXED batch where another queued prompt is allowed (there the turn does not end `rejected`, so the boundary reason alone would not preserve it). `content` is the original prompt the listener rejected; `reason` is the veto text (PromptDecision `block.reason`). NOT a SurfaceEventType: a blocked prompt produces no LLM message and never reaches `deriveMessages()`. + +```ts persistence-catalog +'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:229`](../../packages/core/session/src/types.ts) + +### `steering/*` + +#### `steering/message` — surface + +Steering content injected between steps of a running turn. + +```ts persistence-catalog +'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:262`](../../packages/core/session/src/types.ts) + +### `step/*` + +#### `step/end` — log-only + +Closes step `step` of turn `turn`. + +```ts persistence-catalog +'step/end': { turn: number; step: number } +``` + +Source: [`packages/core/session/src/types.ts:216`](../../packages/core/session/src/types.ts) + +#### `step/start` — log-only + +Opens step `step` of turn `turn` — one model call plus the tool executions it requested. + +```ts persistence-catalog +'step/start': { turn: number; step: number } +``` + +Source: [`packages/core/session/src/types.ts:214`](../../packages/core/session/src/types.ts) + +### `todo/*` + +#### `todo/write` — log-only + +The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`. + +NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — it is durable, replayable UI state, distinct from the conversation history. It is a `SessionEventMap` member riding the existing `session/event` emit, not a first-class Cordis `interface Events` notification, so it has no cordis-catalog row. + +```ts persistence-catalog +'todo/write': { todos: TodoItem[] } +``` + +Types: [TodoItem](../core-data-structures/session.md) + +Source: [`packages/core/session/src/types.ts:276`](../../packages/core/session/src/types.ts) + +### `tool/*` + +#### `tool/call` — log-only + +The model requested one tool invocation: `name` with the raw `arguments` JSON string exactly as the model produced it (unparsed). `callId` pairs the call with its `tool/result`. + +```ts persistence-catalog +'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } +``` + +Types: [CallId](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:250`](../../packages/core/session/src/types.ts) + +#### `tool/result` — surface + +A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + +```ts persistence-catalog +'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } +``` + +Types: [CallId](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:260`](../../packages/core/session/src/types.ts) + +### `turn/*` + +#### `turn/end` — log-only + +Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. + +```ts persistence-catalog +'turn/end': { turn: number; reason: TurnEndReason } +``` + +Types: [TurnEndReason](../core-data-structures/session.md) + +Source: [`packages/core/session/src/types.ts:212`](../../packages/core/session/src/types.ts) + +#### `turn/start` — log-only + +Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). + +```ts persistence-catalog +'turn/start': { turn: number; trigger: TurnTrigger } +``` + +Types: [TurnTrigger](../core-data-structures/session.md) + +Source: [`packages/core/session/src/types.ts:206`](../../packages/core/session/src/types.ts) + +### `user/*` + +#### `user/message` — surface + +A user-visible prompt (queued message drained at turn start). + +```ts persistence-catalog +'user/message': { content: ContentBlock[]; source: MessageSource } +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/core/session/src/types.ts:218`](../../packages/core/session/src/types.ts) diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 12eee7a2b7..04f3910f1c 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -4,7 +4,7 @@ Status: resolved (fix in PR #41 `feat/acp-2-bridge`) ## Executive summary -One stray line — `export default apply` at the bottom of the ACP plugin — made the ACP server crash the moment any editor connected, because the cordis Loader unwraps a default export and threw away the plugin's `inject` declaration along with it. A second, independent bug (an optional service read that fails through Cordis's traceable-shadow proxy) crashed `session/load` for a different reason. Both shipped green: 178 unit tests at 100% line coverage never caught either, because every test mounted the plugin by hand instead of through the real loader, and the only test that drove the failing requests was skipped in CI. The fixes are one-line each; the durable lesson is that **line coverage proved the code ran, not that the feature worked the way it ships** — so we added a no-key end-to-end test that boots the real example through the real loader, plus AGENTS.md rules on plugin export shape and optional-service access. +One stray line — `export default apply` at the bottom of the ACP plugin — made the ACP server crash the moment any editor connected, because the cordis Loader unwraps a default export and threw away the plugin's `inject` declaration along with it. A second, independent bug (an optional service read that fails through Cordis's traceable-shadow proxy) crashed `session/load` for a different reason. Both shipped green: 178 unit tests at 100% line coverage never caught either, because every test mounted the plugin by hand instead of through the real loader, and the only test that drove the failing requests was skipped in CI. The fixes are one-line each; the durable lesson is that **line coverage proved the code ran, not that the feature worked the way it ships** — so we added a no-key end-to-end test that boots the real example through the real loader, plus packages/AGENTS.md rules on plugin export shape and optional-service access. ## Summary @@ -101,7 +101,7 @@ Both bugs share one root process gap: **no test exercised the plugin through its - **`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. +- **[docs/testing.md](../testing.md) rule**: "test the real entry path", line coverage is not behavior coverage — codifies the lesson for every future plugin. ## Lessons diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md new file mode 100644 index 0000000000..ea2f3d7f85 --- /dev/null +++ b/docs/rfc/INDEX.md @@ -0,0 +1,184 @@ +# RFC index + +Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md). + +## Proposed + +### Feature + +| Title | First proposed | +|---|---| +| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | +| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | +| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | +| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | + +### 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 | +| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | + +### 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 | + +### Process + +| Title | First proposed | +|---|---| +| [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 | +| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 | +| [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 | +| [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 | + +### Testing + +| Title | First proposed | +|---|---| +| [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 | +| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | + +## Implemented + +### Feature + +| Title | First proposed | +|---|---| +| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | +| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | +| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | +| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | +| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | +| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | +| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | +| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | +| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 | +| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | + +### Simplification + +| Title | First proposed | +|---|---| +| [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | +| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | +| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | +| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | +| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | +| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | +| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | +| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](implemented/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | +| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | +| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | +| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | +| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | +| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | +| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | +| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | +| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | + +### Architecture + +| Title | First proposed | +|---|---| +| [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 | +| [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 | +| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | +| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | +| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | +| [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 | +| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | +| [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 | +| [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | +| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | +| [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | +| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | +| [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | +| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | +| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | +| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | +| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | +| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 | +| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | +| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | +| [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 | +| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 | +| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 | +| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 | +| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | +| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 | +| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | +| [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | + +### Process + +| Title | First proposed | +|---|---| +| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | +| [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 | +| [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 | +| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 | +| [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | +| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 | +| [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | +| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | +| [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | +| [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | +| [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | +| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | +| [Documentation graph index for maintainers and SDK users](implemented/process/2026-07-03-documentation-graph-atlas.md) | 2026-07-03 | +| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 | +| [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 | +| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | +| [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | +| [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 | + +### 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 | +| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | +| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | +| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | +| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | +| [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | + +## Rejected + +### Simplification + +| Title | First proposed | +|---|---| +| [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 | +| [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 | + +### Architecture + +| 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/README.md b/docs/rfc/README.md index c9cdf9a770..fcb077e0a5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -1,6 +1,6 @@ # RFCs -One kind of design doc lives here. An **RFC** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. (Earlier this split into separate "ADR" and "RFC" trees; they were unified, since most ADRs were simply implemented RFCs.) +One kind of design doc lives here. An **RFC** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. The full list is the generated [INDEX.md](INDEX.md); this file is the contract — where RFCs live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming @@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo ## Classification -Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/verify-rfc-classification.ts` rejects any folder outside the set and asserts this index lists every RFC under the heading matching its path. Adding a new class means amending that gate and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated. +Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and [INDEX.md](INDEX.md) is **generated** from the tree in full (`pnpm run gen-rfc-index` rewrites it from each RFC's path, H1 title, and filename date; the gate fails when it is stale, and rejects an index-shaped row in this file). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the index is generated while this prose stays curated. | Class | What it covers | |---|---| @@ -35,142 +35,75 @@ Write an RFC when a decision is **durable** (it shapes the codebase beyond a sin 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 +## The file format -### Feature +Every RFC follows one in-file format, enforced by `pnpm run verify-rfc-format` ([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts), part of `doc-sync`); the rationale for the format — and the alternatives it rejected — is [the uniform-format RFC](implemented/process/2026-07-05-uniform-rfc-format.md). -| Title | First proposed | -|---|---| -| [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 | +### The header block -### Simplification +The first three lines of every RFC are exactly: -| 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 | +```markdown +# RFC: -### Architecture +Status: <status> +``` -| 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 | +followed by a blank line. The `Status:` value is one of three forms, and must agree with the lifecycle folder the file sits in — the gate cross-checks them: -### Process +- `Status: proposed` +- `Status: implemented` +- `Status: rejected — <why, in one line>` -| 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 | +The status carries no dates and no parentheticals: the filename holds the first-proposed date, git holds everything else, and an "accepted in amended form" note is body content (state the amendment where the decision is stated). The rejection reason is the one status with content, because a rejected RFC's verdict is the fact readers come for. -### Testing +### The body skeleton -| 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 | +Every RFC opens its body with `## Problem` — the motivation, written to stand without the solution. What follows depends on the lifecycle; recurring sections use these canonical names and nothing else, while genuinely bespoke technical sections (package topology, wire contracts, schemas) remain free-form between the required ones. -## Implemented +#### `proposed/` -### Feature +```markdown +## Problem +## Proposal +…bespoke sections… +## Alternatives considered +## Acceptance criteria +## Risks +``` -| Title | First proposed | -|---|---| -| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | -| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | -| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | -| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | -| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | -| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 | +`## Proposal` is the intended change and may legitimately speak in the future tense — plans, migration steps, and open questions belong here while the work is unbuilt. `## Acceptance criteria` says what observable state means done. `## Risks` covers both what could go wrong and what the change knowingly gives up. -### Simplification +#### `implemented/` -| 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 | -| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | -| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +```markdown +## Problem +## Decision +…bespoke sections… +## Alternatives considered +## Consequences +``` -### Architecture +`## Decision` describes shipped reality in the present tense, and the whole file is kept current with it per [implemented/AGENTS.md](implemented/AGENTS.md). `## Consequences` records what the trade-off cost **and** bought. Proposal-era headings are spec-speak here and the gate rejects them: `## Proposal`, `## Plan`, `## Migration plan`, and `## Acceptance criteria` may not appear in an implemented RFC (the [slop checklist](../AGENTS.md) names why). A `## Testing`, `## Deferred`, or `## Related` section is fine where it states present-tense fact. -| 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 | -| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | -| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | -| [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 | +#### `rejected/` -### Process +A rejected RFC is the proposal, frozen: it keeps whatever proposal-time sections it had (including `## Acceptance criteria` or `## Plan`), and the verdict lives on the `Status:` line. Only the header block, the `## Problem` opener, a `## Proposal` section, and the Alternatives-considered mandate below apply. -| 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 | -| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 | -| [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | -| [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | -| [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | -| [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | -| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | +### Alternatives considered — mandatory -### Testing +Every RFC carries an `## Alternatives considered` section: each genuine alternative and why it lost, one bold-led paragraph per alternative or a `### Why not <X>?` subsection per contested one. A decision recorded without what it beat invites re-litigation — the failure RFCs exist to prevent. -| 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 | -| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | -| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | -| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | -| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | +Alternatives are recorded, never invented. An RFC dated before 2026-07-05 whose alternatives are not reconstructible from the record carries this exact comment in place of the section, which the gate accepts for pre-format files only: -## Rejected +```markdown +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +``` -### Simplification +### Moving between lifecycles -| Title | First proposed | -|---|---| -| [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 | +Moving a file between lifecycle folders means updating the `Status:` line and re-satisfying that folder's skeleton in the same change — the gate fails the move otherwise. Concretely, `proposed/` → `implemented/` rewrites `## Proposal` into a present-tense `## Decision`, folds `## Acceptance criteria` and `## Risks` into `## Consequences` (or a present-tense `## Testing`/`## Verification` section for what now pins the behavior), and drops plans in favor of what shipped — the rewrite [implemented/AGENTS.md](implemented/AGENTS.md) requires, made mechanical. `proposed/` → `rejected/` only adds the reason to the `Status:` line and freezes the file. -### Architecture +### Chinese counterparts -| 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 | +A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../i18n/README.md); the machine-checked header tokens (`# RFC: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate owns their consistency. diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index e5ddc5eeea..b4ee7d83d3 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Implemented RFCs -These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type Safety and Documentation", [docs/AGENTS.md](../../AGENTS.md)); this file adds one rule specific to this folder. +These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type safety and documentation", [docs/AGENTS.md](../../AGENTS.md)), and the in-file skeleton — including the proposal→implemented rewrite a lifecycle move owes — is [README.md § The file format](../README.md#the-file-format), gated by `verify-rfc-format`; this file adds one rule specific to this folder. ## Keep an implemented RFC current with what actually shipped @@ -10,6 +10,6 @@ Update it **in place** to state the current truth. Do **not** leave the outdated ### This is not a license to rewrite the *decision* -Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. If the underlying choice itself is reversed or materially changed (not just relocated), that is a new decision: write a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). The line: a refactor that moves where the decision is *realized* → edit this RFC to match; a reversal of *what was decided* → a new RFC. +Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* → a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating. diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 49d55badac..42efb34774 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -1,21 +1,24 @@ # RFC: Provider-neutral content-block vocabulary owned by dsh-llm -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## Context - -The harness needs one internal language for messages that the loop, session log, and all plugins speak. Options: mirror the DeepSeek/OpenAI chat-completions shape (zero mapping for the first provider, awkward for rich content), adopt Anthropic's Messages block structure verbatim (battle-tested, but our canonical types would mirror a third-party API we don't target first), or own a vocabulary. +The harness needs one internal language for messages that the loop, session log, and all plugins speak. ## Decision -Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`, `image`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. +Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary. +## Alternatives considered + +- **Mirror the DeepSeek/OpenAI chat-completions shape** — zero mapping cost for the first provider, but awkward for rich content (reasoning, tool results as structured blocks). +- **Adopt Anthropic's Messages block structure verbatim** — battle-tested, but the canonical types would mirror a third-party API the harness does not target first. + ## Consequences -- Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions. +- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md index 22330f4747..c9f07e463e 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -1,10 +1,8 @@ # RFC: Custom typed tool-schema DSL instead of schemastery -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array. @@ -12,7 +10,9 @@ Tool parameters must reach the model as standard JSON Schema (the wire format), A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs<S>` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive. -Schemastery was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly. +## Alternatives considered + +**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index b9a182a4e9..a3240153d0 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -1,10 +1,8 @@ # RFC: Dev-mode invariants over compile-time deep-readonly -Status: implemented (accepted 2026-06-13) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem 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. @@ -17,9 +15,11 @@ Reject the pervasive `DeepReadonly<T>` type flip. Instead: 1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call. 2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`). -The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown `tools/execute` waterfall ends the step), and both `idle→disposed` and `running→disposed` are legal. +The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown tool-execution pipeline step ends the turn), and both `idle→disposed` and `running→disposed` are legal. -`DeepReadonly` was rejected because it is compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise. +## Alternatives considered + +**The pervasive `DeepReadonly<T>` type flip** ([the rejected proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)) — compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and it would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md index 3b20a19ef3..aeea55103d 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -1,12 +1,10 @@ # RFC: Event-sourced sessions with derived message history -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## Context - -The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统,session完全可回放). Two models were considered: a mutable message array with events fired as notifications (simpler, but state and log can diverge), or event-sourcing where the log IS the state. +The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统,session完全可回放). ## Decision @@ -16,9 +14,13 @@ Appends are synchronous (the hot path never blocks on I/O); `session/event` is a Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested). +## Alternatives considered + +**A mutable message array with events fired as notifications** — simpler, but state and log can diverge; with event-sourcing the log IS the state, so divergence is structurally impossible. + ## Consequences - Replay, trace, and telemetry are structurally guaranteed, not bolted on. - Persistence stays a plugin concern; the in-memory store ships in dsh-session. -- The event vocabulary is merge-extensible (plugins add e.g. compaction events); it carries a TODO(review) marker until the first persistence plugin and real adapter exercise it. +- The event vocabulary is merge-extensible (plugins add e.g. compaction events); [session persistence](2026-06-14-session-persistence.md) froze its shape once the log became durable. - Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation. 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 c8869c0616..501f8c7331 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 @@ -1,26 +1,28 @@ # RFC: Microkernel — extension via Cordis event taxonomy, one concrete loop -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## Context - -The product principle (see the 微内核Harness实现思路 design doc) is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core. Candidate mechanisms considered: a purpose-built middleware stack (koa-compose style), an explicit phase state machine plugins can insert into, or Cordis's native event system. +The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core. ## Decision 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`, `system-prompt/assemble`. +- **waterfall** (around-middleware) where plugins mutate or veto: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/post-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`. The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it. +## Alternatives considered + +**A purpose-built middleware stack (koa-compose style)** and **an explicit phase state machine plugins insert into** — both would re-implement dispatch, disposal, and reload semantics that Cordis's native event system already provides; as Cordis effects, listeners get HMR and disposal for free. + ## Consequences -- Every MVP feature maps to a listener (the "plugin sanity checklist" in docs/architecture.md is the proof obligation, kept current). +- Every MVP feature maps to a listener (the [feature → mechanism map](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map) is the proof obligation, kept current). - HMR and disposal come free: listeners and registrations are Cordis effects. - Waterfall semantics (call `next()` or short-circuit) are non-obvious and must be taught — documented in AGENTS.md and covered by composition tests. - The loop must be defensive: plugin exceptions are contained at turn level, steering from any seam is never stranded (regression-tested). diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md index 5c0aad9eb2..33bf241c74 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -1,10 +1,8 @@ # RFC: Runtime arg validation at the model boundary -Status: implemented (accepted 2026-06-13) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem `defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk. @@ -17,6 +15,8 @@ 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](../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; [a property test](../testing/2026-06-11-property-based-testing.md) generates args satisfying a spec and asserts they pass `validateArgs` (with targeted corruptions rejected), closing that drift risk mechanically. - `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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 9e4eb0f2fc..35c1917926 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -1,10 +1,8 @@ # RFC: Structured error taxonomy -Status: implemented (accepted 2026-06-14) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically. @@ -24,3 +22,5 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge. - `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay. - Reverting this PR returns the earlier errors to plain `Error`+`code` form; nothing else in the stack depends on the shared base. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md index b78aa622ed..5c78ef4280 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md @@ -1,17 +1,19 @@ # RFC: Tool schemas are part of the system-prompt assembly -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## Context - -On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment. The alternative — the loop querying the tool registry separately from the prompt service — splits one concern across two seams. +On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment. ## Decision `PromptAssembly { sections, tools }`: the system-prompt service collects ordered text sections AND tool schemas (the tool registry auto-contributes a provider). The loop consumes one assembly per step; adapters map `sections` to the provider's system slot and `tools` to the wire `tools` field. The `system-prompt/assemble` waterfall is therefore a single interception point for everything the model is told up front — tool filtering (ToolSearch / progressive disclosure) is an assembly rewrite, same as prompt edits. +## Alternatives considered + +**The loop queries the tool registry separately from the prompt service** — splits one coherent concern across two seams, and every interception that wants to shape "what the model is told" (tool filtering, plan mode) would need two listeners on two surfaces instead of one assembly rewrite. + ## Consequences - One waterfall governs the model's standing context; plugins like plan mode can swap prompt text and visible tools in one listener. diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md index e66440c3ee..907e4cd86b 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md @@ -1,10 +1,8 @@ # RFC: Capability seams — interface / implementation / consumer split -Status: implemented (accepted 2026-06-13) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. @@ -20,10 +18,13 @@ A swappable capability is **three packages**: Implementation and consumer then evolve independently: a sandboxed executor replaces `dsh-bash-local` without touching a tool schema. -Alternatives considered: **one combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point). **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/execute` veto seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names. - The split is not mandatory when the parts are genuinely one concern: the LLM seam folds interface + consumer into `dsh-llm` (the consumer is the loop itself, not a swappable schema surface) with adapters as the implementation packages. Don't split preemptively — a capability with one conceivable implementation and one consumer stays one package until a second appears. +## Alternatives considered + +- **One combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point). +- **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/pre-execute` deny/ask seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this RFC names. + ## 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. diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index ecd21b5cc3..7f30240ffd 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -1,10 +1,8 @@ # RFC: Two LLM adapters as a design-verification twin -Status: implemented (accepted 2026-06-13) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem `dsh-llm` owns a provider-neutral streaming vocabulary — the `StreamChunk` protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`) and the content-block types ([the content-block vocabulary](2026-06-11-content-block-vocabulary.md)). A vocabulary defined against a single adapter risks baking that adapter's quirks into the "neutral" contract: anything the one implementation happens to do becomes the de-facto spec, and the abstraction is unverified until a second provider arrives — by which point the leak is expensive to fix. @@ -17,7 +15,10 @@ Ship **two** adapters against the one contract from the start, deliberately buil The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden. -Alternatives considered: **a single adapter** — less code and half the e2e cost, but leaves the "provider-neutral" claim unverified; the vocabulary would encode DeepSeek-via-fetch assumptions silently. **A mock second adapter** — cheaper but doesn't exercise a real provider's wire quirks, so it proves little. The twin is real-on-real. +## Alternatives considered + +- **A single adapter** — less code and half the e2e cost, but leaves the "provider-neutral" claim unverified; the vocabulary would encode DeepSeek-via-fetch assumptions silently. +- **A mock second adapter** — cheaper but doesn't exercise a real provider's wire quirks, so it proves little. The twin is real-on-real. ## Consequences 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 7abe7ec8ed..135c1671f6 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -1,12 +1,10 @@ # RFC: Session persistence as an abstract service over the existing `SessionEvent` -Status: implemented (proposed 2026-06-14, accepted 2026-06-15) - -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +Status: implemented > Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices. -## Context +## Problem 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. @@ -27,6 +25,10 @@ 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. +## Alternatives considered + +Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. + 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/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 42e231430b..10a6209f70 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -1,10 +1,8 @@ # RFC: Every session event is enclosed in a turn -Status: implemented (accepted 2026-06-15) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a turn — between the last `turn/end` and the next `turn/start` — since such an event would be swept into the next turn's interrupted close. @@ -15,8 +13,6 @@ That assumption did not hold. Two paths recorded events outside any turn: In case 2, if the injected `context/message` is the last event before a flush/dispose (no later turn appends a `turn/end`), `scanLog` treats it as crash debris and **drops it on resume** — the injected context is durably on disk but silently lost on reload. Case 1 was benign in isolation (a `user/message` is always followed by the turn it triggered) but made the "what may appear outside a turn" rule fuzzy. -Two ways to fix it: relax the *reader* (let `scanLog` commit events that sit outside an open turn), or constrain the *producer* (make every event turn-enclosed so the reader's simple "last `turn/end`" rule is both correct and complete). We chose the producer-side invariant: a single, checkable rule beats a more permissive boundary scan that has to reason about partial turns *and* loose between-turn events. - ## Decision **Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely: @@ -29,6 +25,10 @@ Two ways to fix it: relax the *reader* (let `scanLog` commit events that sit out The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching. +## Alternatives considered + +**Relax the reader instead of constraining the producer** — let `scanLog` commit events that sit outside an open turn. Rejected: a single, checkable producer-side rule beats a more permissive boundary scan that has to reason about partial turns *and* loose between-turn events. + ## Consequences The turn is now the *single* durability/replay boundary, so [session persistence](2026-06-14-session-persistence.md)'s crash-recovery rule is complete, not merely sufficient: an interrupted final turn is closed (with a synthetic `turn/end {interrupted}`) and its real events preserved, with zero risk of conflating between-turn context into it, because there is no between-turn context. `scanLog` stays simple (one possibly-open final turn, never a loose between-turn event), and an idle background-task notice survives persist + resume. @@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload. -The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush` (which runs as the post-`turn/end` durability checkpoint) or a throwing `agent/turn-end` listener (after `closeTurn` already appended `turn/end`) — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So those post-turn failures are reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. +The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md new file mode 100644 index 0000000000..0ecf3e0e78 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -0,0 +1,158 @@ +# RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools + +Status: implemented + +## Problem + +The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. + +That couples three concerns that change independently: + +1. The filesystem contract: what operations plugins can ask for. +2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later. +3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting. + +Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment. + +We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface. + +## Decision + +Filesystem access is a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary. +2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem. +3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`, and is the executor that dispatches the `fs/*` events. + +The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. + +The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape. + +The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. + +The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. Direct directory listing was later added by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md). + +Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. + +Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.) + +## Package topology + +The filesystem seam uses the same dependency direction as the bash trio: + +```text +@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local + consumer interface implementation +``` + +`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the `fs/*` policy event vocabulary. It carries no observed-state store and no owner-derivation shape; the events pass an opaque `object` actor that the provider never reads, and the `dsh-fs-policy` plugin owns the owner-derivation shape and the observed-state store on top of those events. + +`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, and contains all direct `node:fs` / `node:path` access. It holds no observed-state store — freshness is a version token the backend mints and the policy plugin records. + +`@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. + +The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `write`, and `edit`) by composing the per-tool registration helpers. It injects `fs` and never imports an implementation package. + +## `ctx.fs` contract + +`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics. + +The interface covers these semantic operations: + +- Resolve a model/plugin-supplied path into a backend-defined target. +- Stat target metadata without reading file contents. +- Read a bounded UTF-8 text page from a target. +- Create or replace a UTF-8 text file. +- Edit an existing UTF-8 text file by literal replacement. + +The provider seam also carries the freshness hooks that policy builds on — but the observed-state store and owner derivation live in the `dsh-fs-policy` plugin, not on `ctx.fs`: + +- The backend mints an opaque `version` token per target (in `stat` and in every read/mutation outcome). +- `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section. +- The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`). + +Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap<owner, Map<targetKey, version>>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This RFC first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate RFCs replaced that with the freshness-based policy plugin described here.) + +Path resolution is explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. + +Resolved targets must expose at least three concepts: + +- The original input path, for diagnostics. +- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path. +- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend. + +Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token. + +The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views. + +Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit. + +Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state. + +Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer does not force local-style composition. + +The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy. + +Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped. Directory-listing-specific codes were added later by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).) + +## Tool consumer behavior + +`@deepseek-ai/dsh-tool-fs` is the model-facing consumer. It owns tool names, JSON schemas, argument validation at the model boundary, prompt sections, and result formatting. It does not own filesystem execution. + +The first tool suite contains: + +- `read`: inspect a UTF-8 text file and return line-numbered content with pagination guidance. +- `write`: create or fully replace a UTF-8 text file. +- `edit`: update an existing UTF-8 text file by replacing literal text, requiring a unique match by default and allowing an explicit replace-all mode. + +Each tool follows the same execution shape: + +1. Validate and normalize model arguments. +2. Call the appropriate `ctx.fs` operation. +3. Format the result as `ContentBlock[]` for the model. +4. Let thrown backend/tool errors flow through `ToolRegistry.execute()`, which converts them into `isError` tool results. + +The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required. + +The tool package keeps model-facing contracts stable when backends change: a local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas do not change solely because the backend changes. + +The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation. + +The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`. + +## Testing + +Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here. + +The defensive-pattern classes this repo has been bitten by are pinned directly: + +- **Atomic-write temp-file safety.** Write/edit stage through a private random `0700` directory next to the target with an exclusive owner-only (`'wx'`, `0o600`) temp file, cleanup on failure, and a final atomic rename — mirroring the bash spill-file rules, because predictable world-readable temp paths invite symlink races and disclosure. Tests assert the permissions and that a pre-existing temp path is not clobbered; this primitive is a standing requirement of the seam. +- **`targetKey` identity through symlinks.** Two input paths resolving to the same realpath share one observed-state entry: a `read` via path A satisfies the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path is detected through the other. +- **Concurrency / stale races.** Two concurrent write/edit operations against the same target settle deterministically — one succeeds, the other is rejected with `FS_STALE_VERSION` — and a successful edit refreshes recorded state so the same owner's next edit proceeds. +- **HMR safety and disposal.** Disposing the backend's fiber withdraws the `ctx.fs` provider; a later provider starts with no inherited state. + +## Alternatives considered + +- **Model-facing tools directly over `node:fs`** — the tool package would own execution policy, path resolution, atomic writes, text decoding, and edit semantics at once, coupling the three independently-changing concerns the Problem names and churning schemas on any backend swap. +- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same interface/implementation/consumer split as bash, and the combined name never became public surface. +- **Observed-state on `ctx.fs`** — the shape this RFC first landed; superseded by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [the event-gate RFC](2026-06-26-file-context-as-event-gate.md): a sandboxed/remote backend must not inherit model-facing observation policy, so the provider keeps only the version token and the optional version-guarded mutation. + +## Consequences + +**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`. + +**The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths. + +**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this RFC is trying to avoid. + +**Edit semantics are race-prone by nature.** Literal edit is a read-modify-write operation; the guard is the backend's atomic mutation critical section plus the optional version expectation, so concurrent edits settle deterministically — one wins, the other gets `FS_STALE_VERSION`. + +**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This RFC first placed it inside the filesystem seam; the split-fs-seam RFC then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events. + +**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. + +**Observed-state persistence is deferred.** Observed state lives in memory (the `WeakMap` inside `dsh-fs-policy`), so a resumed session conservatively requires files to be read again before write/edit until a future session-event or persistence mechanism makes observation replayable. + +**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and stays limited to the error vocabulary. + +**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive. 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 35cb2eb6b3..e786adbfe8 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 @@ -6,9 +6,9 @@ Status: implemented 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 +## Decision -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. +Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token. ### 1. Queue-aware `Agent.cancel(reason?)` @@ -24,7 +24,9 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit Background-task ownership moved from a `tool-bash` plugin-local `Map<string, Agent>` 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) +## Verification + +These invariants hold and are pinned by tests: - 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. @@ -37,6 +39,12 @@ The bash owner-token comparison relies on `session.header.id` being unique among The planned resolution is to remove the precondition by construction — see [unify the agent id and the session id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md): once an agent IS its session (one id), the registry's existing unique-`agentId` check is a unique-session-id guarantee and no two live agents can share a session token. -## Notes +## Alternatives considered + +- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` seam — rejected: one read path, no redundant API. +- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing the session's `onAppend` detach against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths. +- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface RFC](../simplification/2026-06-20-public-agent-stop-surface.md)). + +## Consequences 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/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 3f4419b5ca..4c8b5a81e3 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -1,8 +1,8 @@ # RFC: Session surface — a linked list over the event log for LLM message derivation -Status: implemented (accepted 2026-06-18) +Status: implemented -## Context +## Problem The `Session` event log is the single source of truth ([event-sourced sessions](2026-06-11-event-sourced-sessions.md)), but the only view over it was `deriveMessages()` — a linear scan that filtered and transformed raw events into `Message[]`. This creates problems for session-history-manipulating plugins (compaction, tool-call result pruning, etc.). Without a central mechanism, each plugin would need to wrap `agent/request` to rewrite the message list — a pattern that suffers from listener-ordering fragility, provides no durable record of what was changed, and forces repeated changes to the core `deriveMessages()` whenever a new manipulation is added. A central hub in the `session` package, with a provenance-recording mechanism and enough flexibility for future plugins to manipulate session history through a stable API, lays a solid foundation for plugin development. @@ -29,13 +29,11 @@ export type SurfaceOp = 2. **Replace** — remove nodes from `start` through `end` (both inclusive) and insert a new node in their place. Both `start` and `end` must be valid surface node seqs in the current surface; `start === end` replaces a single node. The node's `sourceEventSeqs` must contain every shadowed surface node. The shadowed events remain in the log but are no longer on the surface. -The both-ends-inclusive design was chosen over half-open `[start, endExclusive)` because the surface is a doubly-linked list — both ends are naturally named by node seqs, and single-node replacement (`start === end`) is a common case that reads naturally with inclusive semantics. - ### SurfaceManager: delta-based, not full rebuild A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding). -Why delta processing? The naive approach (a dirty flag + full rebuild on every access) would be O(N²) over a session's lifetime — every single-event append triggers a complete scan of all prior events. Delta processing is O(1) when no new events and O(new events) when new events arrive. +Delta processing is O(1) when no new events and O(new events) when new events arrive. `deriveMessages()` uses the surface when surface markers exist, falling back to the existing linear scan for sessions without markers (backward compatibility). @@ -53,6 +51,12 @@ The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empt Because the surface is the SOLE derivation path, a surface-eligible event that carries no `surfaceOp` marker is invisible to `deriveMessages()` — it would land in the log yet silently drop from history on resume/fork. `append`'s typed overload makes the marker mandatory for `SurfaceEventType` events at compile time, but only when the type argument is a SPECIFIC literal; when it widens to the `SessionEventType` union (a caller iterating raw events, e.g. `for (const e of log) append(e.type, e.data)`) the conditional rest collapses to optional and the compiler stops enforcing it. The marker requirement is therefore ALSO checked at runtime in two places: `append` itself throws on a marker-less surface-eligible event (covering the union-widening loophole), and the `Session` seed constructor re-checks the same invariant (alongside its seq-contiguity and JSON-serializability checks) so a seed/load/fork — which arrives as raw `SessionEvent[]`, bypassing `append` — is REJECTED rather than constructing a session that resumes with missing history. (No backward-compat path for surface-less logs: per the pre-release stance there is no persisted user data to preserve, so such a log is rejected, not upgraded.) +## Alternatives considered + +- **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`. +- **Half-open `[start, endExclusive)` replace ranges** — rejected: the surface is a doubly-linked list whose ends are naturally named by node seqs, and single-node replacement (`start === end`) reads naturally with inclusive semantics. +- **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events. + ## Consequences - **`packages/core/session`**: New `surface.ts` (`SurfaceManager`), new types (`SurfaceOp`, `SurfaceIntent`), new fields on `SessionEvent`, modified `append()` (third required `SurfaceIntent` param), refactored `deriveMessages()` (walks the surface as the sole derivation path), surface-aware `repair.ts`. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). 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 44ae67f872..5923ff434f 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 @@ -1,6 +1,6 @@ # RFC: Shared persistence write coordinator -Status: implemented (proposed and accepted 2026-06-18, implemented 2026-06-20) +Status: implemented ## Problem @@ -32,6 +32,11 @@ The single design choice that keeps the seam clean: the crash-repair "where is t 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 +## Alternatives considered + +- **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all. +- **A wider hook surface** — each candidate hook folded away: there is no separate `materialize` hook (the materialize-write must commit atomically with the first event batch inside `appendBatch`), no separate create-collision probe (it is `loadStored(id) !== undefined`), and no coordinator pass-through for `list()` (listing needs none of the orchestration). + +## Consequences 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/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 0b4975089d..8171a05d30 100644 --- a/docs/rfc/implemented/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: implemented (proposed and accepted 2026-06-20) +Status: implemented ## Problem @@ -12,7 +12,7 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin **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<string, Session>()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map<string, Agent>()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map<string, …>()` 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<Agent, string>()`, `loadingIds = new Set<string>()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map<string, …>` 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 +## Decision A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. @@ -40,7 +40,9 @@ export function OwnerToken(id: string): OwnerToken { } ``` -## Why a distinct OwnerToken brand (not SessionId) +## Alternatives considered + +### Why not typing `owner` as `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/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. @@ -54,15 +56,12 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded<string>` 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 +## Verification -- `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 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<string, …>`; 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). +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. -## Risks / what we give up +## Consequences -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The 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). +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. - **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/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index b6c30819dd..0d8d6c02b4 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 @@ -8,13 +8,13 @@ An example folder is supposed to be *thin* — the variable wiring of a demo, no 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 +## Decision 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. 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-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 § Service map): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension. - **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. -- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle moved into that bin, owned by the app. The `bin.ts` files are coverage-excluded (a self-executing CLI entry, like the old `start.ts`) and driven by the keyless Loader-path tests. +- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. - **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-core`. @@ -30,18 +30,20 @@ The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Val 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? +## Alternatives considered + +### 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. +- `demo:echo` / `demo:repl` / `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 +## Consequences - **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. 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 8b198ed9c6..89b50eb3df 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -8,7 +8,7 @@ Status: implemented 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 +## Decision Packages are grouped by modular role at a uniform `packages/<group>/<pkg>/` depth. Group directories are pure containers (no `package.json`); every package keeps its `@deepseek-ai/dsh-<pkg>` name — this is repo structure and maintenance policy, not package renaming. @@ -62,6 +62,12 @@ Two doc-sync/hygiene gates keep the structure and its references honest, so the - `scripts/verify-package-paths.ts` flags a `packages/<path>` 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/<group>/<pkg>` 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 +## Alternatives considered + +- **A third tier (`adapters/` / `impls/` under each family)** — rejected: uniform depth 2 keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package. +- **Nesting persistence under `core/session/`** — rejected: the storage backends form a parallel capability family mirroring `llm/` and `bash/`, while the session log itself stays core product API. +- **`ui-stdio` under `ui/`** — rejected: it is example-coupled dev support, not a product surface; `acp` is the only `ui/` member because an editor actually drives it. + +## Consequences 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/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md new file mode 100644 index 0000000000..df4914f105 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -0,0 +1,82 @@ +# RFC: Mandatory `User-Agent` attribution for provider requests + +Status: implemented + +## Problem + +LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, and traffic analytics. Before this RFC the harness only partially did this: the hand-rolled DeepSeek adapter sent a hand-copied `User-Agent` constant (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin sent no harness-owned headers at all (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters could therefore omit attribution silently, and a library-backed adapter could drift from the hand-rolled adapter even though [the twin-adapter RFC](2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations. + +The immediate prompt came from OpenRouter's [App Attribution](https://openrouter.ai/docs/app-attribution) docs. OpenRouter creates app pages and rankings from `HTTP-Referer` plus display/category headers. That is valuable, but it is not the HTTP standard for application identity. The risk is adopting OpenRouter's exact header set as if it were universal, then leaking provider-specific headers to direct DeepSeek requests, future OpenAI/Anthropic/Vertex adapters, test servers, or proxies that log unknown fields indefinitely. + +## Investigation + +- **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard. +- **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers." +- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them. +- **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request." +- **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header. +- **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally. +- **Request-body `user` or `metadata` fields are not app attribution.** Some model APIs expose a stable end-user identifier, request metadata, labels, or project/account headers. Those are useful for abuse monitoring, internal billing, dashboards, or trace correlation, but they either identify the end user rather than the product, are provider-specific body schema, or are not guaranteed to be forwarded through OpenAI-compatible gateways. They are not a substitute for a static application identity header. +- **SDK telemetry headers identify the SDK, not the app.** Official and third-party SDKs often send library/version headers. Those help the SDK maintainer debug their client, but they do not identify the harness as the application unless the application explicitly supplies a product attribution layer. +- **pi-ai has a first-class header hook.** `@earendil-works/pi-ai`'s `StreamOptions.headers` merges caller headers last over provider defaults, so a library-backed adapter can satisfy the same wire contract as the hand-rolled one without wrapping or upstream work. The mock-server suites assert arrival on the wire for both adapters. + +## Decision + +Provider request attribution is mandatory at the LLM adapter boundary, using the standard `User-Agent` header only. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving that `User-Agent` reaches the wire (a mock server asserting received headers; for a library-backed adapter, the library's header hook feeding the same mock-server assertion). + +Do **not** implement OpenRouter app attribution in this RFC. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this RFC. + +The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attribution.ts`), not by individual adapters. `AppIdentity` contains only public product facts needed to build `User-Agent`, and the default `APP_IDENTITY` settles the values the proposal left open: + +- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-RFC wire value and the repo/org identity) +- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists + +The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. + +Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field names are case-insensitive on the wire): + +| Target | Mapping | +|---|---| +| All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. | +| Direct DeepSeek endpoint | `User-Agent`; do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. | +| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this RFC. | +| Future providers | `User-Agent` only unless a later provider-specific RFC accepts additional headers. Do not reuse `HTTP-Referer` by analogy. | + +Endpoint detection is not part of this RFC because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names. + +## Verification + +The landed contract: + +- `dsh-llm` documents the mandatory `User-Agent` attribution contract for `LlmAdapter` authors (`LlmAdapter` JSDoc, package README, and the adapter-contract section of `docs/core-data-structures/llm-streaming.md`). +- A shared helper (`attributionHeaders` / `userAgent`) constructs the app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy version constants. +- `dsh-llm-deepseek` sends the shared `User-Agent` on every request and its mock-server suite asserts the exact value. +- `dsh-llm-pi-ai` sends the same `User-Agent` through pi-ai's `StreamOptions.headers` hook and its mock-server suite asserts the exact value. +- No adapter sends OpenRouter-specific attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, `X-OpenRouter-Categories`) as part of this RFC. +- No app-attribution field carries secrets, local paths, session ids, prompt text, model output, user email, or per-user stable identifiers. +- The adapter READMEs state the `User-Agent` attribution policy and explicitly avoid documenting OpenRouter app attribution as implemented behavior. + +## Alternatives considered + +**OpenRouter app attribution now.** Rejected for this RFC. Sending `HTTP-Referer` plus `X-OpenRouter-Title` would satisfy OpenRouter rankings, but those headers are a provider-specific product feature, not the provider-neutral model-request attribution this RFC is trying to standardize. Supporting them should be an explicit OpenRouter adapter/mode decision later, not hidden inside the first shared attribution helper. + +**OpenRouter headers everywhere.** Rejected. It would treat a custom OpenRouter contract as a universal standard and send fields with misleading semantics to providers that did not ask for them. It also risks using `HTTP-Referer` as a generic app URL field even though standard HTTP already has `User-Agent` for product identity and `Referer` for a different browsing-context concept. + +**Only provider account/project identity.** Rejected. Organization/project headers, API keys, cloud accounts, and billing projects identify who pays or owns the request, not which application is sending traffic. They also expose no public app title/category and do not help gateways like OpenRouter build app rankings. + +**End-user `user`/`metadata` fields.** Rejected for this RFC. Those are valuable for abuse monitoring and customer support but describe the human or tenant behind a request. App attribution must be static product identity and safe to send on every request. + +**Config-only opt-in attribution.** Rejected. A default-off setting is exactly how adapters keep drifting. The policy is mandatory default attribution with overrideable public values, not optional attribution. + +**Product-named token (`deepseek-harness-sdk`).** Considered for the `User-Agent` token, since the product name is DeepSeek Harness SDK. `deepseek-harness` won on continuity: it is the identity providers already see from this codebase, it matches the org/repo identity and package scope, and it keeps wire attribution stable while display copy carries the product name. + +## Consequences + +**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. + +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics). + +**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. + +**OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md new file mode 100644 index 0000000000..a03df16c8c --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -0,0 +1,347 @@ +# RFC: Web capability seam - stable tools over multiple providers + +Status: implemented + +## Problem + +The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. + +The model-facing surface must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. + +Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract. + +There is also a provider-selection question. Existing `tool-bash` and `tool-fs` can rely on Cordis `inject` because there is one backend service key. Web has two independent capabilities (`search` and `fetch`) and potentially multiple providers per capability. `inject: ['web']` proves the seam exists; it does not prove a usable search or fetch provider exists, and it does not define which provider should win when several are registered. + +## Decision + +Web access is a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-local`. +3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. + +Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. + +Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. + +`dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: + +- `web_search` is registered when web search is enabled for the product/app, `web_fetch` when web fetch is. +- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. +- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. + +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. + +The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. + +## Package topology + +The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and a richer selection policy (a configured provider id, or auto-select when exactly one usable provider is registered), so the `WebError` an execution throws can explain why a search or fetch capability cannot run. + +The dependency direction mirrors bash and filesystem: + +```text +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa + consumer interface implementation + <--depends on-- @deepseek-ai/dsh-web-search-perplexity + implementation + <--depends on-- @deepseek-ai/dsh-web-search-deepseek + implementation + <--depends on-- @deepseek-ai/dsh-web-fetch-local + implementation +``` + +At runtime, provider packages register capabilities with `ctx.web`; `tool-web` registers stable tools with `ctx.tools` and executes through the seam: + +```mermaid +flowchart LR + exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] + perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web + toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web + toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] + toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] +``` + +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. + +Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. + +`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. + +## `ctx.web` contract + +`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: + +```ts +interface WebSearchProvider { + readonly id: string + status(): WebProviderStatus + search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> +} + +interface WebFetchProvider { + readonly id: string + status(): WebProviderStatus + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> +} + +interface WebService { + registerSearchProvider(provider: WebSearchProvider): () => void + registerFetchProvider(provider: WebFetchProvider): () => void + + search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> +} + +interface WebExecContext { + readonly signal?: AbortSignal +} +``` + +`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`. + +Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber. + +## Provider status and selection + +Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. + +`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state. + +`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner. + +```ts +type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } +``` + +Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. + +| Situation | Execution behavior | +|---|---| +| A configured provider id is registered and `status().available === true` | runs that provider | +| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` | +| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider | +| No provider id is configured and no provider for that kind is registered | fails with `WEB_PROVIDER_UNAVAILABLE` | +| No provider id is configured and multiple usable providers for that kind are registered | fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order | +| No provider id is configured and providers exist but none are usable | fails with `WEB_PROVIDER_UNAVAILABLE` | + +The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs set explicit provider ids: + +```yaml +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: exa + fetchProvider: local-http + +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`. + +`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider. + +## Search request and result schema + +The `web_search` model-facing tool is small. The only model-facing argument is: + +- `query`: required string. + +`max_results` is NOT exposed to the model. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam. + +`maxResults` flows tool → seam → provider, and the bound is enforced on the way back: + +- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`. +- `ctx.web` passes the request through to the selected provider unchanged. +- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization. +- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. + +The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly. + +```ts +interface WebSearchRequest { + readonly query: string + /** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */ + readonly maxResults?: number +} + +interface WebSearchResult { + readonly providerId: string + readonly query: string + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} + +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. + +Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. + +Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies. + +## Fetch request and result schema + +The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `local-http`. It fetches bytes from a concrete URL, applies the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).) + +The seam request stays smaller than OpenCode's model-facing tool: + +- `url`: required HTTP(S) URL. +- `timeoutMs`: optional positive number capped by the provider. + +The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional. + +HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure. + +```ts +interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} + +interface WebFetchResult { + readonly providerId: string + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} + +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair. + +`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim). + +The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries. + +The fetch provider's resource controls: + +- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected. +- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced. +- Abort signals propagate through network fetches and expensive decoding. +- Only same-origin redirects are followed automatically; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) +- Requests carry an explicit product user agent rather than silently impersonating a browser. + +SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets. + +## Tool consumer behavior + +`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`. + +`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. + +Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically. + +Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. + +The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. + +The model-facing output is text-first because tool results are `ContentBlock[]`, but the seam outcome stays structured so UI presentation and future adapters do not have to scrape rendered text. + +## Errors + +`dsh-web` defines `WebError extends HarnessError` with stable codes, covering only states that callers may reasonably branch on: + +- `WEB_PROVIDER_UNAVAILABLE` +- `WEB_PROVIDER_CONFIGURED_MISSING` +- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` +- `WEB_PROVIDER_AMBIGUOUS` +- `WEB_DUPLICATE_PROVIDER` +- `WEB_INVALID_URL` +- `WEB_BLOCKED_URL` +- `WEB_REDIRECT_BLOCKED` +- `WEB_FETCH_TOO_LARGE` +- `WEB_FETCH_TIMEOUT` +- `WEB_ABORTED` +- `WEB_UNSUPPORTED_CONTENT_TYPE` +- `WEB_PROVIDER_ERROR` + +`WEB_DUPLICATE_PROVIDER` is thrown synchronously from `registerSearchProvider` / `registerFetchProvider` when an id is already registered for that capability kind (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); it is a registration-time programming error, not an execution outcome, but shares the `WebError` code space so callers see one taxonomy. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure in `web-fetch-local` (DNS, connection refused, TLS); there is deliberately no separate `WEB_NETWORK` code — the provider sets a descriptive message so the model and logs can tell a network failure from a provider API failure. + +Tool execution lets these errors flow through `ToolRegistry.execute()`, which already converts `HarnessError` into an error tool result with structured metadata. The model gets a readable error message; hooks, tests, and UI code can route on the stable code. + +## Testing + +Each layer is pinned at its own seam: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-local`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. + +## Alternatives considered + +### Let each provider register its own model-facing tool + +This matches the most flexible provider-plugin systems: every provider can expose its full native schema. It is rejected for the harness because it gives provider packages ownership of model-facing names, descriptions, prompt guidance, and result formatting. Multiple search providers would produce duplicate tool names or provider-specific tool names, and the model would learn backend details instead of a stable product capability. + +### Put provider dispatch directly in `dsh-tool-web` + +This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema. + +### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) + +Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" config surface — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately. + +### Choose the first registered provider + +Rejected. Registration order is not a product policy. It can change with config order, plugin loading, HMR, or refactors. Provider selection must be explicit, or automatic only when exactly one usable provider exists. + +### Treat Firecrawl/Exa/Tavily/Parallel extraction as fetch + +Rejected for the first version. Those providers often return extracted or summarized content rather than a concrete HTTP response. If the product needs extraction, design `web_extract` or deliberately widen the fetch seam later. + +### Mirror Claude Code's `url + prompt` WebFetch shape + +Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`. + +## Consequences + +**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. + +**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. + +**Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. + +**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error. + +**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can reach sensitive network targets or exfiltrate data through URLs. Only the basic transport hygiene ships (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets. + +**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance. + +## Deferred work + +- SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets. +- A `pdf` `WebFetchBody` kind: the `local-http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. +- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. +- Permission policy integration once the deferred permission system lands. +- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. + +## Open questions + +- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution? +- Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both? diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md new file mode 100644 index 0000000000..c890acb5c0 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -0,0 +1,171 @@ +# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface + +Status: implemented + +## Problem + +[The split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`. + +This couples three things that should be separable: + +1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`. +2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-fs-policy` plugin's job. +3. **The recording of observed state** — a side effect that should never block the tool from functioning. + +Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening. + +## Decision + +Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. + +```text +tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; + emits fs policy events; renders results +policy dsh-fs-policy plugin: listens to fs/write-intent + + fs/edit-intent (single-slot waterfall) and fs/observed + (emit) events; adds observed-state + freshness. +provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version + guard is OPTIONAL; owns the fs policy event vocabulary +provider dsh-fs-local local implementation of ctx.fs +``` + +The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. + +`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. + +## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat + +`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: + +- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`. +- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. + +This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation. + +## Provider contract change: the version guard is optional + +For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional: + +```ts ignore-check +// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> +// undefined → unconditionally create-or-overwrite (bare default) +// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged] +// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged] + +// editText: expected becomes optional (was the required { version: FsVersion }). +editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> +// undefined → unconditionally replace literal text in the current content (bare default); +// a missing target still reports FS_STALE_VERSION +// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) +``` + +The `FsWriteIntent` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-fs-policy` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment". + +## Event vocabulary (owned by `dsh-fs`) + +The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. + +These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). + +**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. + +**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider. + +The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. + +```ts +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' + +interface Events { + /** + * Single-slot decision: produce the write expectation for the next + * ctx.fs.writeText. The default returns undefined (unconditional create-or- + * overwrite — the bare provider). The policy listener returns createIfAbsent + * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). + * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall + */ + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined> + /** + * Single-slot decision: produce the optional version guard for the next + * ctx.fs.editText. The default returns undefined (unconditional edit of the + * current content — the bare provider; no stat). The policy listener returns + * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or + * has not observed the target. Does NOT call next(): one decision. @mode waterfall + */ + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + /** + * Record that an actor observed a target at a version, after a successful + * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap + * write); the tool does not guard the emit, so a throwing listener surfaces as + * the tool's isError result. No listener ⇒ nothing recorded. + * @mode emit + */ + 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +} +``` + +The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (like `agent/request`, which the loop dispatches with no `this`), not service-bound waterfalls (like `llm/stream`). The dispatcher is the `dsh-tool-fs` plugin, which is not a service. + +## Tool contract (`dsh-tool-fs`) + +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance. + +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. + +`dsh-tool-fs` is a single root plugin that registers all three tools (`read`/`write`/`edit`), mirroring `dsh-tool-bash`. It injects `fs` (plus `tools`/`systemPrompt`), never `fileContext`. (The original proposal also exposed each tool as a `/read`/`/write`/`/edit` subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (`applyReadTool`/`applyWriteTool`/`applyEditTool`) remain internal modules the root plugin composes.) + +`stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: + +- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). +- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. + +The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. + +**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. + +## Policy plugin contract (`dsh-fs-policy`) + +`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap<owner, Map<targetKey, { version }>>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. + +- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. +- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. +- `fs/observed` listener: `record(owner, key, version)`. + +An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). + +`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`. + +## Bare-provider behavior (no `dsh-fs-policy`) + +This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-fs-policy`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-fs-policy` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: + +- **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`). +- **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check. +- **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code. + +Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-fs-policy` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-fs-policy` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. + +## Supersedes + +This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change. + +## Verification + +The decoupling and its semantics are pinned by tests: a bare-provider config (no `dsh-fs-policy`) boots the `dsh-tool-fs` root plugin and `read`/`write` (create and overwrite)/`edit` work against the real `dsh-fs-local` — an unread edit and an unread overwrite both succeed, proving the tool carries no `fileContext` dependency, while the same operations with `dsh-fs-policy` present are rejected `FS_NOT_OBSERVED` / gated `createIfAbsent`. A second `fs/edit-intent` listener registered after `dsh-fs-policy` is asserted NOT reached (first-wins short-circuit). A stale-read edit reports `FS_STALE_VERSION` through provider CAS, with `dsh-fs-policy` performing no `stat`; the tool's `stat` budget (read = 1, write = 0, edit = 0, on both paths) is asserted directly. Model-facing schemas stayed byte-for-byte unchanged, so snapshot transcript goldens are unaffected. + +## Alternatives considered + +- **Keep `ctx.fileContext` as an in-path method service** — the shape [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) first landed; rejected because the tool could not run without the policy layer, making policy load-bearing for basic operation instead of an opt-in tightening. +- **Policy-side version checking** (`dsh-fs-policy` stats and compares in its waterfall handler) — rejected for the TOCTOU gap between that check and the tool's actual write; the provider's mutation critical section is the only race-free place, so the policy only chooses the CAS basis and gates on prior observation. +- **Per-tool `/read`/`/write`/`/edit` subpath plugins** — dropped on implementation: no consumer needed a single-tool deployment, and subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries; the per-tool registration helpers remain internal modules the root plugin composes. + +## Consequences + +- **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each. +- **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure. +- **Single policy occupant, first-wins by convention.** The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-fs-policy` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`. +- **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-fs-policy` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-fs-policy` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools. diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md new file mode 100644 index 0000000000..e6ecc974d7 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -0,0 +1,31 @@ +# RFC: stdin + extra env on the bash seam + +Status: implemented + +## Problem + +The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs. + +**These fields are NOT a new security boundary.** It is tempting to frame arbitrary-stdin / arbitrary-env as "dangerous, so gate who may use them" — but that framing is wrong, because a model driving the `bash` tool **already** has equivalent power through ordinary shell syntax: `FOO=bar cmd` sets an env var, a heredoc or `printf … | cmd` feeds arbitrary stdin. Adding `env`/`stdin` as seam fields grants the model no capability it lacks. In particular they cannot exfiltrate the harness's ambient credentials: the real control for that is the **credential scrub** in [dsh-bash-local](../../../../packages/bash/bash-local)'s `childEnv()`, which strips `*KEY*`/`*SECRET*`/`*TOKEN*` from `process.env` before the child sees it (see [docs/defensive-patterns.md](../../../defensive-patterns.md) § "Never hand untrusted output the ambient environment or predictable paths"). The scrub works regardless of these fields — a model cannot read a value that is not in the environment, and tool-call arguments are static JSON, never shell-evaluated, so a model cannot write `env: {LEAK: $DEEPSEEK_API_KEY}` and have it expand. So the security question is already answered by the scrub; this RFC is only about giving trusted in-process callers a clean way to pass a JSON payload + `CLAUDE_*` vars without routing them through model-visible shell text. + +## Decision + +Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecRequest` (the model-/plugin-facing request) and `BashExecSpec` (the resolved spec `run`/`start` act on), and thread them through `dsh-bash-local`: `resolve()` carries them verbatim, `run()`/`start()` pass them to `runBash`, which writes the bytes to the child's stdin and merges the extra env. + +Three deliberate choices: + +1. **The model-facing `bash` tool simply does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only; a model that includes `env`/`stdin` keys in its tool-call arguments simply has them ignored. A regression guard (`tool-bash` "does not forward env/stdin" tests) drives the real tool with those extra args and asserts the recorded request carries neither field — its purpose is to catch a future refactor that blindly spreads `...args` into the request and silently starts forwarding model input into the post-scrub `env` merge, NOT to defend a trust boundary. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it). + +2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. + +3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. + +`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. + +## Alternatives considered + +An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a speculative surface with no consumer. If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then. + +## Consequences + +A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged (the credential scrub, not these fields, is what bounds it), and the `bash` tool's request-building stays the single place that decides which fields a model call carries — guarded by a test that fails if a refactor starts forwarding model input. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md new file mode 100644 index 0000000000..9375a6e248 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -0,0 +1,37 @@ +# RFC: Event-domain semantics — session is the fact log, agent is the live surface + +Status: implemented + +## Problem + +The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy RFC](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred: + +- `session/*` carries the durable, event-sourced log (`SessionEventMap`). +- `agent/*` carries live runtime signals that hand a plugin the `Agent` handle. +- `tools/*` carries the tool registry + execution seam. + +Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. + +This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the `hook/*` durable log, the bridges) build on. + +## Decision + +**Three domains, one job each, with a single boundary rule.** + +- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path. +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`). +- **`tools/*` — the tool registry + execution seam.** + +**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. + +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). + +## Consequences + +- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless). +- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. +- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. +- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that RFC's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`. +- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md new file mode 100644 index 0000000000..d0656c9327 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -0,0 +1,32 @@ +# RFC: Resolve filesystem paths against the caller's session cwd + +Status: implemented + +## Problem + +The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. + +The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide. + +## Decision + +Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. + +- `FileSystem.resolve` widens to `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. An options object (not a positional `cwd?`) leaves room for future resolution hints without another signature change. +- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). +- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default. + +## Alternatives considered + +### Why the caller supplies the cwd (not the provider) + +The provider seam must not depend on `dsh-agent` / `dsh-session` — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the `ToolExecution` (`exec`), which carries the agent, so the tool is the right place to project `exec → cwd` and hand the provider a plain string. This is the "explicit > implicit at package seams" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches `dsh-tool-bash` one-to-one, so the two model-facing file surfaces resolve paths identically. + +The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` returns `undefined` rather than `process.cwd()` when there is no session, so the tool never manufactures a base the provider would otherwise choose. + +## Consequences + +- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it. +- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets. +- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional. +- The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace. diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md new file mode 100644 index 0000000000..45d5a77e46 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -0,0 +1,59 @@ +# RFC: Result-time applied-hunk diffs for file mutations + +Status: implemented + +## Problem + +The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string`→`new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair. + +Driving `claude-agent-acp`'s own ACP bridge shows what a full editor diff looks like: after the mutation applies, it emits a SECOND `tool_call_update` whose diff is the **applied hunk with ±3 context lines** (and one hunk per changed site for `replace_all`), reconstructed from the tool's `structuredPatch`. That result-time hunk is what makes Zed show the change *in place* in the file rather than as a floating snippet. Our tools stopped at the call-time snippet; the completed result carried only the plain "updated successfully" text, no diff. + +The obstacle is a seam boundary: `presentResult(args, result)` is a **pure function of `args` + the model-facing `result` (`{content, isError}`)** — it runs on live streaming AND on session-log replay, so it must be replay-deterministic and cannot do I/O. It never sees the file's before/after content, and `FsEditOutcome`/`FsWriteOutcome` carried only a replacement count + version, not the text. So there was no way to compute — or even carry — an applied hunk to the presenter. + +## Decision + +Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff. + +### 1. A `meta` channel on the tool result (core) + +`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`: + +```ts ignore-check +type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } +``` + +`meta` is an opaque payload the core never interprets — typed `unknown` at every seam (the tool that produced it owns and narrows its shape). It MUST be JSON-serializable: the registry threads it onto the `tool/result` **session event**, and `Session.append` runtime-validates all event data with the existing `isJsonValue` predicate, so a non-serializable `meta` is rejected at the source. On replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. Typing `meta` as `unknown` (rather than a shared serializable-value type) keeps the tools core free of a dependency it would otherwise take just to name the type, and the runtime `isJsonValue` gate — not the static type — is what actually enforces serializability. + +This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. + +### 2. The tool computes the hunk; the backend returns before/after (fs) + +Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: + +- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. +- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A contextual hunk is computed only when a before-version exists — edit always; write on overwrite; a create has no before, matching `claude-agent-acp`'s empty `structuredPatch` on create. But the completed `tool_call_update` is ALWAYS a `diff` card for a successful mutation: an ACP `tool_call_update.content` REPLACES the call's content, so rendering the model-facing result text would clobber the pending diff. So `write`'s result falls back to an args-derived whole-file diff (`oldText: null`) when it has no contextual hunk (a create, or an overwrite whose content is unchanged), and `edit` — which always changes content — always has a hunk. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and falls through to the generic error rendering (its message must show). + +### 3. The bridge renders a `diff` result card + +`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result diff **supersedes** the call-time snippet (and keeps the model-facing result text from clobbering it) — the two-update sequence (call snippet, then result diff) matches `claude-agent-acp` exactly. + +## Alternatives considered + +**Hand-rolling or vendoring the diff algorithm.** Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). + +## Consequences + +`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency. + +## Non-goals + +- **Live incremental diff streaming.** The hunk is computed once, after the mutation completes; there is no per-keystroke diff. +- **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and the result renders a whole-file diff (`oldText: null`) rather than a contextual hunk. +- **Rename/move diffs.** Only content diffs of a single resolved path. +- **Bounding the overwrite diff basis.** An overwrite reads the whole prior file into memory to compute the contextual hunk (on top of the new content already held), so a very large text overwrite allocates both texts for a UI-only diff. A future refinement can bound the pre-read and fall back to a whole-file / no contextual diff above a size threshold; tracked as `TODO(overwrite-diff-bound)` at the read site. + +## Related + +- Completes the one remaining representation difference named as a non-goal in [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) — that RFC's Non-goals section is updated to record that applied-hunk diffs shipped here. +- Builds on the [filesystem capability seam](2026-06-17-filesystem-capability-seam.md) (the before/after are storage facts the backend returns) and [event-sourced sessions](2026-06-11-event-sourced-sessions.md) (the `meta` payload persists on the `tool/result` event, so replay reproduces the card). +- The `meta` channel is deliberately generic: a future tool (a structured search, a data-table result) can attach its own durable result presentation without another core change. diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md new file mode 100644 index 0000000000..cdaf152d73 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -0,0 +1,80 @@ +# RFC: Tagged render-intent union for tool-call presentation + +Status: implemented + +## Problem + +A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy: + +- The call-side and result-side `terminal` fields overlap, and the bridge reconciles a `content` block AND a `terminal` block AND `rawInput` per call, stitching them together with ad-hoc conditionals. +- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. +- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. + +The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot-golden replay path). + +## Decision + +Replace the optional-field bag with a **`card`-tagged discriminated union**. A tool declares one render intent per call/result; the bridge switches on the tag. + +```ts ignore-check +type FileLocation = { path: string; line?: number } +type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file + +// presentCall → ToolCallView +type ToolCallView = GenericCallView | TerminalCallView | DiffCallView +interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] } +interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string } +interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] } + +// presentResult → ToolResultView +type ToolResultView = GenericResultView | TerminalResultView +interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] } +interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string } +``` + +`card` is **required** on every variant — a real discriminant, not an optional default. The bridge does `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`. The union is **closed** (per the [switch-exhaustiveness convention](../../../../AGENTS.md)): a fourth render intent (a table, a chart) needs new bridge code to render it anyway, so a plugin-added variant that the bridge silently drops would be worse than a compile error. Adding a variant breaks compilation at the bridge switch — exactly the signal we want. + +### Why a tagged union beats the field-bag + +- **Invalid states become unrepresentable.** A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these. +- **The bridge switches instead of stitching.** One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented. +- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'`; the bridge emits an ACP `{type:'diff', path, oldText, newText}` `ToolCallContent` (already in the SDK's `ToolCallContent` union, previously unused by the bridge). This is the affordance the redesign unlocks. + +### Producer mapping + +- `dsh-tool-fs` read → `generic` (`kind:'read'`, a follow-along `location`); write → `diff` (`oldText:null`); edit → `diff` (`oldText:old_string || null`, `newText:new_string ?? ''`). This mirrors `claude-agent-acp`'s `toolInfoFromToolUse` Read/Write/Edit arms field-for-field. +- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` and `bash_output`/`bash_kill` → `generic`. +- `dsh-tool-todo` → `generic`. + +### Terminal fallback ownership + +`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte. + +### Purity preserved + +`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`. + +## Relative-path display titles + +`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names. + +## Alternatives considered + +- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met. +- **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch. +- **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all. + +## Consequences + +A new render intent is a compile-breaking change at the bridge switch — deliberately: rendering code must exist before a card kind does. Invalid card/field combinations are now unrepresentable, and the bash fallback derivation lives in the bridge, so a tool returns one structured shape. The bar for a fourth card (a table, a chart) is writing its bridge arm in the same change. + +## Non-goals + +- **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering RFC's own deferred follow-ups, untouched here. + +## Related + +- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. +- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card. +- Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). +- The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card. diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md new file mode 100644 index 0000000000..050c0f3590 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -0,0 +1,51 @@ +# RFC: Add direct directory listing to the filesystem seam + +Status: implemented + +## Problem + +`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`. + +The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `<name>/SKILL.md` or `<name>.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack. + +This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation. + +## Decision + +Add `FileSystem.listDir(target, signal?)` to `@deepseek-ai/dsh-fs`. + +`listDir` lists one directory level only. It returns direct children in stable name order and includes: + +- `name`: the child basename. +- `type`: `file`, `directory`, or `other`. +- `target`: the resolved child `FsTarget`. +- `version`: cheap metadata when available. +- `size`: regular-file size when available. + +It never reads file contents. Recursive traversal, globbing, pagination, search, file watching, and model-facing rendering are intentionally out of scope. + +The local backend implements this through `readdir({ withFileTypes: true })`, `resolveLocalTarget`, and metadata `stat`/`realpath` probes. The result order is deterministic (`name.localeCompare`) to keep prompt/listing output stable for future consumers and improve prefix-cache reuse. + +Broken or disappeared children may be represented as `type: 'other'` without `version`/`size`; they do not abort the whole listing. Permission or backend I/O failures while listing the directory or resolving/probing child metadata fail the whole listing with structured `FsError` codes: + +- `FS_NOT_FOUND` for missing targets. +- `FS_NOT_DIRECTORY` for existing non-directory targets. +- `FS_PERMISSION_DENIED` for permission failures. +- `FS_IO_ERROR` for other backend I/O failures. +- `FS_ABORTED` for aborted calls. + +## Alternatives considered + +**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately. + +**Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends. + +**Make `listDir` recursive or glob-shaped.** Rejected for now. Skill-root discovery only needs direct children, and a simple direct listing is the smallest backend contract future consumers can safely compose. + +**Skip children that fail metadata resolution.** Rejected. The API promises resolved child targets, so permission/IO failures while resolving a child are contract failures. Broken or disappeared children are the exception because they can still be represented without claiming a live resolved file. + +## Consequences + +Every filesystem backend must now implement one additional provider primitive. That is deliberate foundation work while the harness is still unreleased, but it does mean future sandboxed/remote backends need to define equivalent direct-child listing behavior. + +The capability remains provider-facing. Until a consumer lands, ACP/model sessions will still need existing tools such as `bash` for directory listing. The absence of a model-facing `listdir` tool is expected, not a wiring failure. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md new file mode 100644 index 0000000000..24a525307e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -0,0 +1,70 @@ +# RFC: Prompt variables and tool-guidance ownership + +Status: implemented + +## Problem + +The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted. + +**The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. + +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. + +**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are coding-agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. + +**The fork tool's description was false.** `dsh-tool-subagent` hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the `subagent_fork` instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: `PromptSection.name` was documented "(diagnostics / dedup)" but duplicates were silently accepted. + +## Decision + +**One principle: every fact in the prompt has exactly one owner.** The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's `description`. Cross-call habits a description cannot carry → the tool package's prompt section. Identity and behavior → the deployment's persona, and nothing else. + +### Assemble context + +`SystemPrompt.assemble(context)` takes an `AssembleContext` — declared EMPTY and merge-extensible in `dsh-system-prompt` (the package stays agnostic of who assembles); `dsh-agent` declaration-merges `agent?: Agent` onto it (a new type-level edge `agent → system-prompt`, no cycle — `tools` already depends on both). The loop passes `{ agent }` each step; section text providers become `string | ((context) => string)` (zero-arg providers stay valid), and the `system-prompt/assemble` waterfall gains the context parameter so a listener can filter or extend per agent. + +### Prompt variables + +Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. + +`dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. + +### Persona as the order-0 section + +`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and `deployment:persona` at order 0, whose text is the plugin's own `persona` config. The persona is per-DEPLOYMENT, not per-agent: every agent in the context (subagents included) renders the same one, `AgentOptions.systemPrompt` is deleted along with the per-agent forwarding plumbing (the app configs' `systemPrompt` keys become a `persona` key routed to this plugin through `dsh-agent-core`), and the ACP bridge and `dsh-tool-subagent` stop carrying persona configuration entirely. The loop's special-case join is deleted: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. Order bands are now convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. + +### Tool guidance ownership + +Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. + +### The subagent context contract + +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). + +## Alternatives considered + +- **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) +- **Inject the model name via the `agent/request` waterfall** — prompt text composed in two places, and `agent/pre-step`'s `fullSystemPrompt` would omit it, so compaction would measure a prompt that is not what the model sees. +- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this RFC cures. +- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. +- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. +- **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). + +## Out of scope + +- Further variables (`date`, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here. +- A config `cwd` for pre-created stdio agents (would let the stdio persona use `{{cwd}}` and partition persistence by real path) — deferred until the session-cwd story is revisited. + +## Shipped invariants + +- `renderPrompt(assemble({ agent }))` for the coding-agent example renders the persona FIRST (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. +- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. +- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. +- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. + +## Consequences + +- Every fact in the assembled prompt now has exactly one owner, and the hand-maintained tool prose in leaf YAML is gone: loading or dropping a tool plugin no longer means editing any deployment's persona. +- `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted. +- While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted. +- Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud. +- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it. diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md new file mode 100644 index 0000000000..46e7ea4e71 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -0,0 +1,34 @@ +# RFC: Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed` + +Status: implemented + +## Problem + +[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. + +The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). + +## Decision + +The registry announces provider membership as typed events, and the consumer mirrors them instead of assuming order: + +- **`subagent/provider-added(provider)`** — a provider became resolvable in the `ctx.subagents` registry. Emitted on registration. +- **`subagent/provider-removed(name)`** — a provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Emitted from the registration's disposer. + +`dsh-tool-subagent` mirrors its named provider's lifecycle: it registers the tool when the provider is (or becomes) available — deriving the wording from that provider at that moment — unregisters the tool when the provider goes away, and re-derives on re-registration (HMR reload). While the provider is absent the tool does not exist, which cannot lie to the model. There is deliberately NO load-order requirement left to document: the events make the ordering question disappear instead of pinning it. + +The events also complete the seam's vocabulary: `ctx.subagents` is a named registry on which multiple delegation backends coexist (`spawn`, `fork`, `acp`), and a registry whose contents other plugins derive state from should announce membership changes as typed events rather than requiring polling or load-order faith. + +## Alternatives considered + +- **Resolving the provider at `apply` time and throwing when absent (a load-order requirement)** — the first implementation, rejected after review reproduced the failure above. Documenting the requirement ("list backends first") would pin a guarantee the Loader does not make. +- **Retrying the lookup (poll until the provider appears)** — converges eventually but invents a private readiness protocol beside the one the framework already has (effect registration + disposal); it also cannot notice a provider LEAVING, so HMR would strand a tool whose wording describes a disposed backend. +- **Section-only subagent wording, lazily resolved at assemble time** — tolerates any load order too, but moves tool-choice guidance out of the DESCRIPTION, contradicting the ownership rule the prompt-variables RFC establishes (per-tool semantics and when-to-use belong in the description). Reactive registration keeps the description authoritative AND order-free. +- **Keying wording off the provider NAME instead of the provider object** — `providerName` is itself config, so a renamed provider silently gets the wrong words; deriving from the resolved provider's own `inheritsParentContext` cannot drift. + +## Consequences + +- Consumers deriving state from a named provider react to `subagent/provider-added`/`-removed` instead of reading the registry at `apply` time; `dsh-tool-subagent` is the reference implementation. +- **The two emits carry asymmetric failure semantics, deliberately.** `provider-removed` fires inside the registration's disposer and is delivered with PER-LISTENER containment (the service's `emitLifecycle`, not raw `ctx.emit`, which halts dispatch on the first throw): a throwing subscriber is logged, never starves a later mirror into holding a stale tool, and never disrupts the backend fiber's teardown — dispose reaches quiescence. `provider-added` propagates: it fires at registration time, where a throwing listener unwinds the yielded rollback — the same fail-loud register-time semantics as the system-prompt registries. The run-time backstop bounds what a stale mirror could cost anyway: `start()` re-resolves the provider by name per run, so a tool that outlived its provider fails that call cleanly instead of dispatching into a dead backend. The [events catalog](../../../cordis-catalog/events.md) carries the exact signatures, and the [producer/consumer map](../../../event-producer-consumer.md) shows `dsh-subagent` emitting and `dsh-tool-subagent` consuming both events. +- **A window where the tool is absent.** Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's `tools/change` emit keeps prompt assembly current. +- **Two waiting fibers sharing a `toolName` is an invalid config caught late.** If two loads of `dsh-tool-subagent` name different providers but the same `toolName`, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives. `TODO(subagent-dup-toolname)` in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md new file mode 100644 index 0000000000..adad17472d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -0,0 +1,110 @@ +# RFC: Filesystem tool schemas — model-facing read/write/edit shapes + +Status: implemented + +## Problem + +[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`, plus the `dsh-fs-policy` policy plugin), and the observed-file/stale-version policy for read-before-write/edit checks — which the [split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFCs moved off `ctx.fs` into the `dsh-fs-policy` plugin on the `fs/*` event gate. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. + +The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype. + +## Decision + +`@deepseek-ai/dsh-tool-fs` exposes these three model-facing tools in the first filesystem suite: + +| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | +|---|---|---|---|---|---| +| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES | + +The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into `ctx.fs` calls and `fs/*` event dispatches. + +## Tool schemas + +### `read` + +`read` inspects a UTF-8 text file and returns line-numbered content. + +Arguments: + +- `file_path: string` — required. Path to read, resolved by `ctx.fs`. +- `offset?: number` — optional. 1-based first line to return. Defaults to the first line. +- `limit?: number` — optional. Maximum number of lines to return. Defaults and caps are implementation details of `dsh-tool-fs` / `ctx.fs`. + +Non-goals for the first pass: + +- No PDF `pages` argument. +- No image or multimodal file reads. +- No directory listing through `read`; if needed, listing becomes a separate future tool. + +### `write` + +`write` creates or fully replaces a UTF-8 text file. + +Arguments: + +- `file_path: string` — required. Path to write, resolved by `ctx.fs`. +- `content: string` — required. Full UTF-8 text content to write. + +Under the default fs-policy, updating an existing file with `write` requires a prior observation (a read/write/edit) of that file by the same execution context; the `dsh-fs-policy` plugin supplies the observed version as the stale guard on `fs/write-intent`. Creating a new file does not require a prior observation. With the policy plugin absent, `write` is an unconditional bare-provider create-or-overwrite. + +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by backend-produced versions and the policy plugin's observed state, not by asking the model to copy version tokens through the schema. + +### `edit` + +`edit` updates an existing UTF-8 text file by replacing literal text. + +Arguments: + +- `file_path: string` — required. Path to edit, resolved by `ctx.fs`. +- `old_string: string` — required. Literal text to replace. Empty strings are invalid in the first pass. +- `new_string: string` — required. Literal replacement text; an empty string deletes the match. +- `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. + +`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-fs-policy` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. + +The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. + +## Result shape + +The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection. + +Default native projections: + +| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection | +|---|---|---| +| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer | +| `write` | create/update operation, target display path, new file version | concise create/update success text | +| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text | + +The structured outcome does not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result. + +## Deferred + +The following are deliberately out of scope for the first filesystem schema pass: + +- Model-facing `expected_hash`, `expected_version`, or `create_only` parameters. +- Directory listing, glob, grep, and search tools. +- Binary-safe read/write operations. +- PDF/image/multimodal `read`. +- Code Mode projection values for filesystem tools. +- A canonical edit diff format. + +## Testing + +Schema tests pin the required/optional argument set per tool, empty-`old_string` rejection, the `replace_all` default, the snake_case field names, description prose that states the observation policy, and root-plugin suite registration; integration tests execute all three tools through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify the model arguments translate into the expected `ctx.fs` calls and `fs/*` dispatches. + +## Alternatives considered + +- **A Codex-style patch grammar or multi-mode edit API** — rejected: one strict literal replacement mode keeps the model-facing contract simple and lets the backend own exact-match, duplicate-match, line-ending, and stale-version semantics. +- **camelCase argument names (OpenCode's style)** — snake_case aligns with Claude Code and the existing harness tool-schema examples, and naming is public surface once shipped. +- **Model-facing `expected_hash` / `expected_version` / `create_only` parameters** — rejected: stale checks are driven by backend-minted versions and the policy plugin's observed state, never by fragile model-copied tokens. + +## Consequences + +**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the implementation focused, but users may ask for those quickly. They arrive as separate RFCs or focused follow-ups rather than overloads of the initial schema. + +**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. + +**Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract. 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 6e108dc4c1..bca351e906 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 @@ -28,7 +28,12 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c 3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged. 4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal. -## Risks / trade-offs +## Alternatives considered + +- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy. +- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test. + +## Consequences - **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. - **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path. @@ -38,4 +43,4 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c ## Out of scope / non-goals -The text-block baseline stays the no-capability default. Client-side `terminal/create` execution is explicitly rejected (it bypasses `dsh-bash`). Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). +The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 9e08df2fbd..bba12727f7 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -1,14 +1,14 @@ # RFC: Compaction as a capability seam (abstract contract + basic backend) -Status: implemented (2026-06-18; retention/seam reform 2026-06-26) +Status: implemented -## Context +## Problem A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*. -Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. +Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, `SurfaceEventType` is closed to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime. ## Decision @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (chars per token — the `charsPerToken` config, default 4 — + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -103,6 +103,13 @@ Two failure paths, both documented: **Core session repair stays compaction-agnostic — deliberately.** `interruptedTurnClosers` is never taught about `compact/*`. Teaching it would force every future `xxx/start … xxx/end` plugin pair to patch a core module — exactly the coupling the capability-seam architecture exists to avoid. Because the log-only orphan is inert, no special repair is needed: generic turn-repair plus the inertness of an un-landed surface mutation is sufficient. +## Alternatives considered + +- **The full algorithm as concrete interface methods** (only estimation/summarization abstract) — the earlier draft; rejected because it recouples the contract to one retention strategy. Both core methods are abstract; the `protected` estimation/summarization hooks are the backend's private factoring, not the contract's. +- **Compaction on the `agent/request` waterfall** — the earlier cut; rejected for the double-derive it forced and for handing the listener context it structurally cannot compact. The dedicated `agent/pre-step` seam makes the layering correct by construction. +- **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling. +- **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid. + ## Consequences - **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index f733daca7b..28daff14ff 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -2,11 +2,11 @@ Status: implemented -> **Implementation status:** shipped across four PRs. PR1 landed this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; PR2 the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); PR2.5 the nested-agent snapshot infrastructure (see [Per-session snapshot replay for nested agents](../testing/2026-06-22-subagent-snapshot-replay.md)); PR3 the out-of-process `dsh-subagent-acp` backend (see [ACP subagent backend](2026-06-22-acp-subagent-backend.md)). The design below is amended to describe what actually landed. +> The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)). ## Problem -The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed. +The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam; the banner above lists what shipped. The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: @@ -14,11 +14,13 @@ The distinctive requirement — the one that shapes the whole design — is that - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. -## Why not the bash seam shape +## Alternatives considered + +### Why not the bash seam shape The bash seam ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md)) registers exactly one `BashExecutor` per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a **named-provider registry** — each implementation registers under a unique name and a caller picks one by name — mirroring the **LLM adapter registry** (`LlmService.registerAdapter`), not the single-service bash executor. The seam is still three-package (interface / implementation / consumer); only the "one vs. many implementations" axis differs. -## Proposal +## Decision ### The three-package seam @@ -27,11 +29,11 @@ A new package group `packages/subagent/`: | Package | Role | |---|---| | `@deepseek-ai/dsh-subagent` | interface: `SubagentService` (`ctx.subagents`), `SubagentProvider`, `SubagentRun`, the request/result/capability vocabulary, the `subagent/*` events | -| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` (PR2) | -| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log (PR2) | -| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process (PR3) | -| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path (PR1) | -| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` (PR1) | +| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` | +| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log | +| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process | +| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path | +| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | ### The primitive: `start → SubagentRun` @@ -44,7 +46,7 @@ A provider exposes `start(request) → SubagentRun`. The run carries a `result` ### Fork vs. fresh are separate backends, not a flag -Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. +Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. ### Child isolation and the parent log @@ -58,15 +60,13 @@ The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's fin `dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. -## Plan (three PRs, each converged with Codex separately) +## Testing -1. **PR1 — interface + tool + mock.** This RFC, `dsh-subagent` (service, registry, vocabulary, `subagent/*` events), `dsh-subagent-mock` (scripted provider), `dsh-tool-subagent`. Wire the new `packages/subagent/` group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the **real cordis Loader / export path** (a hand-built `ctx.plugin` mount bypasses `unwrapExports` and cannot catch a broken export shape — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). -2. **PR2 — in-process backends.** `dsh-subagent-spawn` and `dsh-subagent-fork` over `ctx.agents.create` + `AgentHandle.dispose`. The fork backend must seed only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix gives the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Depth tracking (parent depth + 1, refused past `maxDepth`) and its exact storage are settled in PR2. -3. **PR3 — ACP backend.** `dsh-subagent-acp` as an ACP client over a configured spawn command (stdio); point it at our own `acp-agent` example to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolve `session/request_permission` via a configured default, consume `session/update` without surfacing it this cut. Decide the `@agentclientprotocol/sdk` version (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existing `dsh-acp` bridge, so re-run its snapshot + e2e). +The seam is tested through the real cordis Loader / export path, not a hand-built `ctx.plugin` mount (which bypasses `unwrapExports` and cannot catch a broken export shape — [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)); the registry pins HMR-safety, duplicate-name rejection, and start-time capability rejection; the nested-agent snapshot scenarios replay keyless in the default gate ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); in-process backends carry real-loop unit tests plus a with-key e2e. -## Risks and deferrals +## Consequences -- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. +- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/pre-execute` deny in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 6be0f6746b..ec608177b4 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -12,7 +12,7 @@ The subagent seam ([the seam RFC](2026-06-21-subagent-capability-seam.md)) was b ### Fresh process per run -Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape. Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need. +Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape. ### Minimal client stub @@ -26,21 +26,31 @@ The provider's `capabilities` are all `false`. An out-of-process child cannot ho ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested); `result` never rejects on a child-level failure, per the seam contract. -### SDK version: stayed on 0.25.1 - -The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this PR has no business rewriting. That cross-cutting connection-API migration is its own PR, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. - ### Security: scrubbed child environment The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. ## Testing -Designed at every tier the backend touches, per the AGENTS.md "design test infrastructure up front" rule: +Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time: - **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage. - **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. -- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [PR2.5](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. +- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. + +## Alternatives considered + +### Why not the 0.28.x SDK bump? + +The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. + +### Why not a persistent child process? + +Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need; each `start` spawning a fresh child mirrors the in-process one-child-per-run shape. + +## Consequences + +Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The parent surfaces only the child's final answer: `session/update` thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via `config.env`. ## Future providers diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index a2287918f9..90bd274eb3 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -51,8 +51,12 @@ Four tiers, designed up front: - **`session/load` replay** — a persisted `todo/write` re-emits the `plan` update when a fresh ACP bridge loads the session. - **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot golden gains the `plan` notification and the log event. -## Alternatives rejected +## Alternatives considered - **In-memory `ctx.todos` service** — would reinvent durability, replay, and `session/load` reconstruction the log gives for free. - **Per-item delta protocol** — only needed for a shared multi-owner list, which is out of scope; whole-list replace is simpler and matches the references. - **Tool in `core/`** — `todo_write` is an extension tool registering on `ctx.tools`, not part of the spine; it lives in its own `packages/todo/` group like other tool families. + +## Consequences + +The todo list is durable, replayable session state: a persisted `todo/write` re-emits the editor's `plan` update on `session/load`, and the log — not plugin memory — is the single source of truth. Whole-list replace means one tool call per update with last-write-wins; there is no delta protocol to reconcile. The event stays off the surface, so a todo update never perturbs the derived model history — the model sees only its own tool call and result. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md new file mode 100644 index 0000000000..630d4dd3ed --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -0,0 +1,67 @@ +# RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges + +Status: implemented + +## Problem + +The harness's extension surface is its typed interception seams ([the interception-seams RFC](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This RFC introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md)). + +The framing that shapes the whole design: **a bridge is a faithfulness adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's only reason to exist is to run an UNMODIFIED external CC/Codex hook with byte-faithful semantics. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, map the neutral outcome onto a seam Decision. + +## Decision + +Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: + +- **`dsh-hooks-claude`** — the CC dialect. Seven hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. Owns CC's per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**. +- **`dsh-hooks-codex`** — the Codex dialect: a deliberate SUBSET. Five hook points (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent/notification/compaction), an always-regex matcher, snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no env and no `${…}` substitution, and a block-only decision model (a Codex hook can never pre-approve, so `allow`/`ask` are not honored). A tool call's payload carries the real `tool_name` (the value the matcher tests, so a config's tool matcher fires) in Codex's `tool_input: { command }` shape. + +### Outcome → Decision mapping + +Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the seam's typed Decision: + +| Seam | CC | Codex | +|---|---|---| +| `agent/session-start` (emit) | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` | +| `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold | +| `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | +| `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | +| `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | +| `subagent/start` (emit) | additionalContext → inject into the live child | — (not a Codex event) | +| `subagent/end` (emit) | observe-only | — | + +### Context source is always the plugin (the mislabel guard) + +`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. + +### Adding context is not a veto — delegate, then fold + +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. So on the context-only path each bridge **delegates via `next()`** and then **folds** its `additionalContext` onto the downstream decision (`concatContext`). The fold differs by seam because the two Decision unions differ: `tools/post-execute` — a downstream `block`/`accept` both carry an `additionalContext` field, so the bridge context rides along either way (a downstream block wins AND keeps the context; a downstream accept keeps its content rewrite and gains the context). `agent/prompt-submit` — a downstream `allow` gains the bridge context (and keeps its own content rewrite / additionalContext), but `PromptDecision.block` carries no context field, so a downstream block drops the bridge context — which is correct: a blocked prompt never reaches the model, so context attached to it is moot. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed, and that both contexts survive when the downstream also adds one. + +### CLAUDE_PROJECT_DIR defaults to the session workspace + +Claude Code always exports `CLAUDE_PROJECT_DIR`, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths. An explicit `config.projectDir` wins; when it is omitted (the default ACP wiring configures only `configPath`), the bridge defaults the env var per-run to the agent's session workspace — the same `session.header.cwd` the hook already runs in — rather than leaving it empty. So a stock project-relative hook works in the default setup. + +### Containment + +The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run — a `prompt`/`agent`/HTTP hook (CC) or an `async: true` / non-command hook (Codex) is parsed-and-skipped with a warning. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop). + +### Where hooks run, and where their config comes from + +Two different cwds, kept distinct on purpose. The hooks **themselves** run in the agent's **session workspace**: for the agent-scoped points the bridge threads the session's `cwd` (`session/new.cwd`, on the session header) to `runHook` as the process working directory, so a hook's `pwd` / relative-file read / marker write operates in the user's project tree, not the server's launch directory. The **config path**, by contrast, is **process-level**: `configPath` is resolved and parsed once at load against the process launch cwd, so a single `hooks.json` applies to the whole process — there is no per-session config discovery that reads a project-local `hooks.json` from each `session/new.cwd` (`TODO(per-session-hook-config)`). This is an honest limitation of the current cut: the example `cordis.yml` documents that its `./hooks.json` is process-level, not per-project. + +## Deferred (faithful-but-degraded) + +- **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field. +- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. +- **Permission `ask`** degrades to `deny` at the `tools/pre-execute` seam (`FIXME(permissions)` in the interception-seams RFC) — there is no interactive permission prompt yet. +- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. +- **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). +- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is sharper: an in-process provider may have already queued the child's prompt before the listener runs, and a short-lived child can finish before the detached inject fires. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; today the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. + +## Alternatives considered + +**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in a deterministic order in the session log, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter. + +## Consequences + +The bridges are thin and readable standalone: the correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in the shared `dsh-hook-protocol`, so each bridge is just config-parse + payload-build + outcome-map. Each is covered at per-file 100% — config-parse branches as unit tests, and the seam mappings end-to-end through the REAL loop + REAL `dsh-bash-local` + REAL shell scripts from a temp `hooks.json` (a scripted mock MODEL is the only stand-in), plus a real-Loader export-shape guard so a stray default export can't silently drop `inject`. Because the seams already carry typed Decisions, a future native plugin needs none of this bridge machinery — it returns a Decision directly. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md new file mode 100644 index 0000000000..fa83dbd779 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -0,0 +1,30 @@ +# RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core + +Status: implemented + +## Problem + +The hooks subsystem ships two bridge plugins: one that runs a user's existing Claude Code (CC) hooks, one for Codex hooks. Studying the reference implementations (`~/repos/refs/claude-code`, `~/repos/refs/codex`) surfaced a decisive fact: **Codex deliberately reimplements a SUBSET of the CC hook protocol.** Its engine reads the same `hooks.json`, uses the same matcher-group shape, the same exit-code/structured-stdout output contract, and the same command-hook execution model — Codex's source even names the engine after Claude's and comments where it "intentionally diverges." So the two bridges would otherwise duplicate the bulk of the protocol. + +This RFC introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not a plugin — it registers and injects nothing) holding the genuinely-identical primitives both bridges build on. The split between shared and per-dialect is the design's center of gravity. + +## Decision + +A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs. + +**Shared (here):** +- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop). +- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). +- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). +- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. +- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. + +**Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). + +## Alternatives considered + +**One parameterized engine.** A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing. + +## Consequences + +The two bridge plugins become thin: parse the config file, pick a matcher mode, build the per-event payload+env, call `runHook` + `mergeHookOutputs`, map the outcome to a Decision, and append `hook/*`. The protocol's correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in one tested place — `hook-protocol` ships with heavy unit tests (matcher per-mode, codec per exit-code/field, runner plumbing with a stub executor, merge precedence, the `hook/*` helpers) at per-file 100%. Input rewrite (`updatedInput`) is parsed but not honored (the deferred [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs+warns on it. The package is a library, so it has no `cordis.yml` load path of its own — its real-load-path coverage comes through the bridge plugins that consume it. diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md new file mode 100644 index 0000000000..7795b044f6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -0,0 +1,48 @@ +# RFC: Interception seams — the typed-Decision surface a hook programs against + +Status: implemented + +## Problem + +The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that **"native hooks" are not a package** — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a *powerful, well-typed canonical event surface*; the CC/Codex bridges (the `dsh-hooks-claude` / `dsh-hooks-codex` packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full `ctx`, typed returns). + +Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's `UserPromptSubmit`), no session-start signal (CC's `SessionStart`), the single `tools/execute` waterfall conflated the pre-gate and post-inspect phases (CC splits `PreToolUse`/`PostToolUse`), and `agent/turn-continuation` returned a bare `boolean` with no room for a force-continue *reason*. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it. + +## Decision + +Add/​reshape the interception seams so every one returns a small, seam-specific **typed Decision union**, and the set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation). + +**New `agent/*` events** (`dsh-agent`): +- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). + +**Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern. + +**Split** the single `tools/execute` waterfall into `tools/pre-execute` (→ `PreToolDecision` allow/deny/ask gate) and `tools/post-execute` (→ `PostToolDecision` accept/block, optionally replacing content or attaching `additionalContext`). Core dispatch sits between them as plain code inside `ToolRegistry.execute`'s outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an `isError` result that `post-execute` listeners can inspect. + +**New `TurnEndReason` variant** `rejected` (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. + +### Three load-bearing loop decisions + +1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. + +2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. + +3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). + +### Pre-tool INPUT rewrite is DEFERRED (the over-reach signal) + +`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement (`PostToolDecision.accept.content`) is safe because `tool/result` is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: `assistant/message` (the model-history source) and `tool/call` (the audit record) are both logged BEFORE execution, and live consumers READ `tool/call.arguments` for presentation (the ACP bridge remembers them for `presentResult`; `dsh-tool-bash` derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own [proposed RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md), and `TODO(pre-tool-input-rewrite)` anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production `tools/execute` listener mutated `exec.arguments`). The low-level capability to mutate `exec` in a `pre-execute` listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract. + +### What this PR does NOT do + +It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) — those belong to the `dsh-hook-protocol` library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (`packages/core/agent-loop/tests/interception.spec.ts`) proves all the seams compose end-to-end through the REAL loop with NO `hook/*` involved — the concrete proof that "native hooks are just a plugin". Compaction (`PreCompact`/`PostCompact`), the Notification hook, Codex `PermissionRequest`, the permission/`ask` system, and the Stop loop-guard remain deferred (`FIXME(permissions)` marks the `ask`→deny degrade). + +## Alternatives considered + +- **Shipping pre-tool INPUT rewrite as part of this seam set** — deferred as the over-reach signal; the section above carries the consistency problem (audit, history, and presentation all read `tool/call.arguments` logged before execution), and [the pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) owns the design. +- **Declaring the durable `hook/*` SessionEvents alongside the seams** — rejected: a native plugin uses the typed Decisions with no hook log at all (the worked example proves it), so the durable log belongs to [the hook-protocol library](2026-06-30-hook-protocol-lib.md), not the seam surface. + +## Consequences + +The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the `dsh-tools` registry runs a two-waterfall pipeline; both are documented in [architecture.md](../../../architecture.md) and the package READMEs, and the decision types in [core-data-structures](../../../core-data-structures/core.md#interception-decisions) + [tools.md](../../../core-data-structures/tools.md). All existing `tools/execute` and `turn-continuation` listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new `rejected` reason to `cancelled` (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the `dsh-hooks-claude` bridge, which is what makes a hook observable end-to-end through ACP. diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md new file mode 100644 index 0000000000..f3b1c9bfb6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -0,0 +1,29 @@ +# RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only) + +Status: implemented + +## Problem + +The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report WHAT a subagent produced without separately reaching for the live run. + +This RFC enriches the end payload. It is deliberately **observe-only**: no control-flow change, no waterfall, no `start()` restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope. + +## Decision + +**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`. + +Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook. + +## Alternatives considered + +**An `agentType` subagent-kind label** (the harness analogue of CC's `subagent_type`) on the request + both lifecycle payloads — an earlier draft shipped it; dropped in review because it is a Claude-Code concept that does not fit our own seam (nothing here interprets it, and the only consumer was a CC-dialect bridge). The CC bridge instead feeds Claude Code's own default matcher value `"general-purpose"` for its SubagentStart/Stop `agent_type` matcher, so this RFC ships ONE enrichment: `lastAssistantMessage`. + +**A control-flow `subagent/end`** — deferred; see below. + +## Why observe-only, and what is deferred + +A control-flow `subagent/end` (an awaited waterfall returning a stop/continue decision, like the other interception seams) would require: reshaping `subagent/end` from emit to waterfall, restructuring `SubagentService.start` to await listeners before settling, and implementing the `resume` capability in the in-process provider so a "continue" can actually re-run the child. That belongs to the background/steering subagent redesign the [capability-seam RFC](2026-06-21-subagent-capability-seam.md) already defers (the same redesign that unifies long-running-tool handling across subagents and bash). This RFC ships the observe-only enrichment a hooks bridge needs today; `FIXME(subagent-continuation)` / `TODO` anchors mark where the control-flow version would land if and when that redesign happens. + +## Consequences + +A hooks bridge (or a native plugin) can now forward the child's `lastAssistantMessage` to a SubagentStop handler by subscribing to the existing emits — no new control-flow surface. The vocabulary addition is documented in [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md) (the events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with one more (optional) field on the end payload — so no snapshot or e2e change is needed. diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md index 69fca053f3..8fd37a1656 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md @@ -1,10 +1,8 @@ # RFC: Doc-sync enforcement -Status: implemented (accepted 2026-06-14) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem AGENTS.md promises that docs and code stay strictly in sync, but the promise was verified by eyeball. Review caught drift twice — a cookbook example contradicting the type policy, and a README citing the wrong `registerAdapter` call. Out-of-sync docs are worse than no docs, and this codebase is built primarily by agents that follow gates far more reliably than prose (mechanical quality gates). Two classes of doc drift are mechanically checkable: code blocks that no longer compile, and the event-taxonomy table that duplicates the `interface Events` declarations. @@ -13,15 +11,20 @@ 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 extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. -2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of a fully-generated `docs/cordis-catalog/events-and-services.md` and its `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected. +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 the fully-generated `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` and their `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 validates the package/vendor build graph that doc-typecheck references. API-extractor golden reports ([the deferred API-extractor-reports proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. +Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references. -**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the AGENTS.md "Markdown is not hard-wrapped" convention. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. +**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the docs/AGENTS.md "one physical line per paragraph" writing rule. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. + +## Alternatives considered + +- **API-extractor golden reports** ([the deferred proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) — deliberately deferred: low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. +- **Generating the taxonomy table from source** instead of verifying names — rejected as more machinery than the problem warranted; the table kept its hand-written Mode/Purpose columns until [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md) superseded the check entirely. ## Consequences - Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle. - Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this). -- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review. Generating the table from source was considered and rejected as more machinery than the problem warrants. +- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review. - API reports remain available to revisit if the packages are ever published externally. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 277a56fa2b..69b1beb554 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md @@ -1,10 +1,8 @@ # RFC: Mechanical quality gates over prose guidelines -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review. @@ -23,3 +21,5 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks - Conventions survive agent turnover; violations fail fast and locally. - The gates themselves are code to maintain; config changes are reviewed like any change. - 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see [the mutation-testing proposal](../../proposed/testing/2026-06-11-mutation-testing.md)). + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md index dc028d7e9b..fd5f01590c 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -1,10 +1,8 @@ # RFC: tsdown for JS bundling instead of dumble -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem The initial build used **dumble**, the cordiverse zero-config esbuild wrapper that upstream Cordis itself builds with — maximum alignment with the vendored packages' conventions (it reads each package.json and infers entries/formats from the `exports` field). But dumble is a liability as a load-bearing tool in this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we were invoking it through a custom orchestration script (`scripts/build.ts`) because it has no workspace mode. @@ -19,7 +17,11 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). - `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. -Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor). +## Alternatives considered + +- **A direct esbuild script** — the most established engine and zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us. +- **pkgroll** — the closest drop-in philosophically, but 78k downloads/week and Rollup-based: strictly weaker maintenance story than tsdown. +- **Keep dumble** — perfect upstream alignment, unacceptable bus factor. ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md index 8b2e29e17c..2aa24907d5 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -1,12 +1,10 @@ # RFC: Vendor Cordis as source, not npm dependencies -Status: implemented (accepted 2026-06-11) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## Context - -DeepSeek Code is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 (a release candidate) when this repo started; the harness depends on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior matters to the agent loop's correctness guarantees. +DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 (a release candidate) when this repo started; the harness depends on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior matters to the agent loop's correctness guarantees. ## Decision @@ -14,6 +12,11 @@ Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logge `vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit. +## Alternatives considered + +- **Depend on the npm packages** — rejected: core was at a release candidate, and the harness leans on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior the agent loop's correctness guarantees depend on; an upstream RC bump could break them without a local fix path. +- **Vendor everything transitively** — rejected: truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm; only the framework layer whose internals matter is owned. + ## Consequences - The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree. diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md index 2d549fa9b9..6ea62b2c4e 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -1,10 +1,8 @@ # RFC: pnpm as the package manager instead of Yarn 4 -Status: implemented (accepted 2026-06-16) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers. @@ -20,7 +18,11 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu - **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, `version: 0.0.1`, `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. - All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy. -Alternatives considered: **keep Yarn 4** (zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager); **npm workspaces** (ubiquitous, but no constraints story and weaker monorepo ergonomics); **pnpm with hoisted linker** (smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move). +## Alternatives considered + +- **Keep Yarn 4** — zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager. +- **npm workspaces** — ubiquitous, but no constraints story and weaker monorepo ergonomics. +- **pnpm with the hoisted linker** — smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move. ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index a45962c70e..0687df250c 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -1,10 +1,8 @@ # RFC: TSC-first build and one tsconfig -Status: implemented (accepted 2026-06-20) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem The current TypeScript build and typecheck setup had these issues: @@ -57,6 +55,11 @@ tsc -b tsconfig.json `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. +## Alternatives considered + +- **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape. +- **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works. + ## Consequences Build responsibilities are clearer: diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md index c4c0ca5afb..db98c2fb7d 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md @@ -1,8 +1,8 @@ # RFC: Markdown cross-link validity linting -Status: implemented (proposed 2026-06-18, accepted 2026-06-18) +Status: implemented -## Context +## Problem Docs in this repo link to each other by relative path — `[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`, `[architecture.md](../../architecture.md)`. Nothing verified those targets exist. A rename or a move silently breaks every inbound link, and the break is invisible until a reader clicks it. [Doc-sync enforcement](2026-06-11-doc-sync-enforcement.md) already mechanized two classes of doc drift (uncompilable code blocks, a stale event-taxonomy table) and [verify-md-wrap](2026-06-11-doc-sync-enforcement.md) a third (hard-wrapped prose) — but a dead cross-link is a fourth, equally mechanical class that was still verified by eyeball. @@ -18,11 +18,14 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into the `doc-sync` script that the lefthook pre-push hook and CI both run, so a broken link fails locally before a push — consistent with [mechanical quality gates](2026-06-11-quality-gates.md). -This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped). Anchor-level checking is a heavier, lower-value follow-up — file-level dead links are the failure that actually bit us. +This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped). + +## Alternatives considered + +**Anchor-level validity checking** — heavier and lower-value; file-level dead links are the failure that actually bit. The scope cut is deliberate: authors verify `#fragment` anchors themselves when linking to one. ## Consequences - Renames and moves that orphan a cross-link now fail the pre-push hook and CI instead of waiting for a reader to click a dead link. This made the RFC reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle. - One more fast tsx script in the `doc-sync` chain; no new dependency (the mdast/GFM stack is already in devDependencies for `verify-md-wrap`). -- Fragment/anchor validity remains unchecked — a known, deliberate scope cut. - The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in [docs/AGENTS.md](../../../AGENTS.md) so authors know the gate exists and why. diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index d589f6136b..37678096ab 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -1,10 +1,8 @@ # RFC: Core-data-structures catalog and the `ts type-equiv` drift gate -Status: implemented (accepted 2026-06-20) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem 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. @@ -23,7 +21,7 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei - 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. +- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), 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`. @@ -40,6 +38,12 @@ The durability requirement was specific: the doc should show the **literal** cur `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. +## Alternatives considered + +- **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won. +- **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. +- **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. + ## 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`). diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index ae07f4b14c..f0b175303c 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -1,10 +1,8 @@ # RFC: Generated cordis events + services catalog -Status: implemented (accepted 2026-06-20) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem 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.<key>` **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. @@ -14,7 +12,7 @@ This is the wiring-axis complement to the [core-data-structures catalog](../../. Generate the catalog from source instead of hand-maintaining a table and verifying a subset. -`scripts/gen-cordis-catalog.ts` 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.<key>` 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. +`scripts/gen-cordis-catalog.ts` walks the `interface Events` and `interface Context` declarations (plus the service classes) with the TypeScript compiler API and emits two sibling pages: `docs/cordis-catalog/events.md` (events grouped by scope, each rendered as signature + mode badge + its source JSDoc, plus the dispatch-mode legend) and `docs/cordis-catalog/services.md` (each `ctx.<key>` with its public method signatures + class JSDoc). The two axes are separate documents — a reader is either finding what to listen to or what to call, and each page scans and deep-links as its own reference instead of one long combined scroll. It mirrors the `gen-module-graph` pattern exactly: `--write` regenerates both, `--check` fails if either committed file is stale, output is deterministic (sorted), and the files are build artifacts that are 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). @@ -25,7 +23,13 @@ Specific choices: - **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. +This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged. + +## Alternatives considered + +- **Verify-don't-generate, as the retired taxonomy check did** — 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-maintained table. +- **Walking the vendor AST for the inherited tier** — rejected for the curated table: the cordis-core `Context` mixes true ctx members with non-service fields, and the pinned vendor surface changes only on a deliberate sync. +- **Reusing `type-equiv.manifest.json` as the signature cross-link map** — rejected for a small hand-curated const: the manifest documents the `…Map` symbols while signatures reference the derived union names, and it lists a few symbols on two pages. ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md index d3fc95399b..2162687225 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -1,8 +1,8 @@ # RFC: Classify RFCs by kind via path-encoded subdirectories -Status: implemented (proposed 2026-06-20, accepted 2026-06-20) +Status: implemented -## Context +## Problem `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. @@ -29,18 +29,18 @@ The `architecture` / `process` line: **architecture** is about the source we shi Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don't-generate, exit non-zero on the first violation): -- **`scripts/verify-rfc-classification.ts`** — the closed set and index completeness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that `README.md` lists every RFC exactly once under the `###` heading matching its `{lifecycle}/{class}` path. The canonical class set lives as a `const` in this script — the machine source of truth — and [the index](../../README.md) documents it in prose; the two are kept in sync by hand (the README's completeness is gated, its class *descriptions* are not). This mirrors `verify-event-taxonomy`, which checks a doc table against source. -- **`scripts/verify-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. +- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the generated [INDEX.md](../../INDEX.md) byte-matches a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the README](../../README.md) documents it in prose; the class *descriptions* stay hand-written, the index is generated. +- **`scripts/verify-doc-refs.ts`** — source comments that cite docs. RFC paths are referenced not only from Markdown but from TypeScript doc comments (root-relative prose like `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`). `verify-md-links` never saw those, so the reorg could have silently orphaned them. This gate scans repo-authored `.ts` under `packages/**` and `examples/**` (excluding built `lib/` and `vendor/`) for `docs/….md` tokens, resolves each root-relative, and asserts it exists. It requires the `.md` extension so extensionless prose (`docs/postmortem/0001`, `docs/architecture.md § Extending The Harness`) is left alone. -### Rejected alternatives +## Alternatives considered - **A `Classification:` prose line** in each file (next to `Status:`), parsed by the gate. Workable, but it duplicates into the file a fact the path can already carry, and a line can disagree with its folder. Path-encoding makes the label and its storage the same thing — there is nothing to keep in sync. - **A `refactor` class.** It overlaps `simplification` almost entirely; the only discriminator anyone reached for was "does observable behavior change?", which `simplification` already encodes (it does not). One class, not two. -- **Auto-generating the README index** from the filesystem. Rejected to keep the index hand-written like every other doc here; the completeness gate gives the same drift-protection without generated Markdown in a curated file. +- **Auto-generating the index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the list is now the fully generated [INDEX.md](../../INDEX.md) while the README prose stays curated. ## Consequences - Every RFC now sits under a class folder, and the index groups by class within each lifecycle. A reader scans one heading to see all simplifications, or all testing decisions. - Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`). -- Adding a class is a deliberate act: amend the `const` in `verify-rfc-classification.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. +- Adding a class is a deliberate act: amend the `const` in `scripts/rfc-index.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. - Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see. diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml new file mode 100644 index 0000000000..ecbd6f196b --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-bilingual-docs-and-pairing-gate.md: 8731fef46b16cfa20d223575c70774cff780a6aa +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: ce2589498ab16cf5ca2f5cdb3f58d031aeb8298f diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md new file mode 100644 index 0000000000..8731fef46b --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -0,0 +1,38 @@ +# RFC: Bilingual documentation via paired sibling files and a pairing gate + +Status: implemented + +English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md) + +## Problem + +This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. + +## Decision + +- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../i18n/terminology.md). +- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), and excluded (generated or bilingual-by-construction) files stay unpaired. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. + +## Alternatives considered + +- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this RFC: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese RFC, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged. +- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged. +- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates. +- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible. +- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express "consistent as of the state this PR introduces", and verifying it requires git history instead of file content. +- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims. + +## Industry precedent + +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service. + +## Consequences + +- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant. +- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, "who confirmed these consistent, and when" is answerable from git blame on the yaml. +- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring. +- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list. +- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. +- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md new file mode 100644 index 0000000000..ce2589498a --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -0,0 +1,38 @@ +# RFC: 通过配对兄弟文件与配对门禁实现双语文档 + +Status: implemented + +[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文 + +## 问题 + +本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。没有机制、纯靠手工维护第二语言,正是译文腐烂的方式:一侧继续演进,另一侧默默地说谎,而没有门禁会注意到。对这类不变式,本仓库一贯的答案是把它编码成机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。 + +## 决策 + +- **配对兄弟文件,两种语言同权。**一对文档是三个兄弟文件:英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典——一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束这对文件的是两侧必须说同样的话,且配对整体合入(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../i18n/terminology.md)。 +- **旁挂记录两侧 blob hash,使一致性可检查。**`foo.i18n.yaml` 保存两侧文件在上一次确认一致状态下各自的完整 git blob hash。此后改了任一侧而没重新确认配对,都能被机械检测出来——纯内容比较、无需查询历史——而且同一个 PR 里改动的文件也能算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)产生一份可评审的 yaml diff:确认一致在 PR 里是一个显式、可见的动作。 +- **`verify-translation-pairing` 加入 `doc-sync`。**门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行:required 的配对存在;任何已存在的配对完整(三个文件齐全)且一致(两个 hash 都匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)保持不配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单是一个棘轮:每个合入的翻译批次把自己的文件加进去,覆盖面只增不减。 +- **翻译是 agent 的工作,由人评审。**进仓的工作流是 [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md) 同一模式:skill 承载工作流,并把真源让给文档。 + +## 曾考虑的替代方案 + +- **英文为正典源、指纹放在译文内**——本 RFC 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 RFC,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的旁挂记录取代了文件内的单向指纹;blob hash 的机制原样保留。 +- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**——否决:本仓库没有把 locale 映射到路由的文档站框架,挪动每个英文文件会搅动所有既有交叉引用,且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑而不是原样工作。 +- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**——否决:适合有独立发布节奏的文档产品,对 monorepo 自己的文档而言过重;还会把译文置于本仓库门禁够不到的地方。 +- **中英混排单文件(一个文件、两种语言)**——否决:每个 diff 都翻倍,破坏一段一行约定的 diff 工效,且局部不一致不可见。 +- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**——否决,改用 blob hash:同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。 +- **比较配对两侧的 git 时间戳(无记录)**——否决:纯格式化的改动会误报,一次无关改动之后提交的另一侧会漏报;只有内容同一性这个信号与门禁的承诺名实相符。 + +## 业界先例 + +带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`)——但这些仓库都没有在 CI 里**强制**配对或一致性;约定纯靠评审维系。一致性自动化存在于中国之外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit、为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计把两者结合:中文生态的文件布局,加 hash 对门禁,再加一个进仓 agent skill(技能)替代 bot 服务。 + +## 后果 + +- 修改已配对文档的任一侧,同一个 PR 就有义务更新另一侧并重新记录配对——门禁把 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。 +- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对一致」可以从 yaml 的 git blame 直接回答。 +- 两侧说法冲突时,没有机械规则裁决谁赢——由 PR 评审裁决。这是同权的代价,是有意接受的:另一个选项(正典语言)禁止中文先行撰写。 +- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让它们的生成器在输出英文的同时输出中文,届时移出排除清单。 +- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(`--list`),不是红的 CI,因此配对按可评审的批次落地,无需一个巨型 PR。 +- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),所以这套机制从不强迫整篇重译。 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 96355941b3..e13192c640 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -1,10 +1,10 @@ # RFC: Generated tool-schema catalog (boot-and-harvest) -Status: implemented (accepted 2026-07-02) +Status: implemented -## Context +## Problem -A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The [cordis events & services catalog](../../../cordis-catalog/events-and-services.md) ([its RFC](2026-06-20-generated-cordis-catalog.md)) documents the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift. +A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift. ## Decision @@ -19,7 +19,7 @@ The cordis catalog is a pure TypeScript-AST pass because every event/service nam - `tool-subagent`'s tool name is `config.toolName ?? 'subagent'` — chosen at load, not a literal. - An MCP plugin can register **raw JSON Schema** directly via `ctx.tools.register()` without `defineTool` at all, so enumerating `defineTool(` call sites structurally under-counts. -The only faithful source of truth is the schema the registry actually holds after the plugin loads. Booting is the [unit-test discipline](../../../../AGENTS.md) "verify the world, not a synthetic stand-in" applied to a doc generator: read the shipped artifact, not a re-derivation of it. +The only faithful source of truth is the schema the registry actually holds after the plugin loads. Booting is the [testing-policy discipline](../../../testing.md) "verify the world, not the self-report" applied to a doc generator: read the shipped artifact, not a re-derivation of it. ### Restoring "nothing silently omitted" @@ -39,6 +39,12 @@ The unit is the PACKAGE, not the deployed tool instance. A package's registered Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck` only extracts `ts*` fences, so a JSON block is invisible to it — no `BlockKind` wiring is needed (unlike the cordis catalog's `ts cordis-catalog` fence, which had to be allowlisted so a bare signature fragment isn't compiled). +## Alternatives considered + +- **A pure TypeScript-AST pass, like the cordis catalog** — tool schemas are not statically knowable (the crux above): runtime spreads, string concatenation, config-chosen names, and raw `ctx.tools.register()` registrations all make an AST-derived doc lie. +- **Inferring each package's boot recipe from its injects** — the "too clever" path [the discover-package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) warns against; the recipe stays hand-written policy while the inventory is discovered and completeness-guarded. +- **A bespoke `ts`-family fence for schema blocks** — unnecessary: a plain ` ```json ` fence is invisible to `doc-typecheck`, so no `BlockKind` allowlisting is needed. + ## Consequences - The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright. diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md new file mode 100644 index 0000000000..11b14a4902 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -0,0 +1,67 @@ +# RFC: Documentation graph index for maintainers and SDK users + +Status: implemented + +## Problem + +The repo already had several high-trust documentation surfaces, each on a different axis: [module-graph.md](../../../module-graph.md) is generated from package `peerDependencies`, the generated [Cordis events](../../../cordis-catalog/events.md) and [services](../../../cordis-catalog/services.md) catalogs are generated from Cordis `Events` and `Context` declarations, [tool-catalog/tools.md](../../../tool-catalog/tools.md) is generated by booting shipped tool plugins, and [core-data-structures/](../../../core-data-structures/core.md) uses `ts type-equiv` blocks to keep pasted type definitions synchronized with source. + +Those references are accurate, but they are mostly catalogs. A maintainer still has to synthesize the relationships: which packages form a capability seam, which app bundles a concrete spine, which event is durable vs live, where a hook or policy plugin can intercept work, and which model-facing tool depends on which service. An SDK user has the same problem from another angle: "Which package do I install or load for the behavior I want, and which event/service/tool do I extend?" + +The hooks subsystem makes event producer/consumer topology and interception points much more important, and the filesystem seam makes capability seams, policy vetoes, tool presentation, and SDK assembly paths much more important — relationship graphs scoped to a small bash/todo/subagent surface would have gone stale immediately. + +## Decision + +Add generated relationship graph docs, indexed at [docs/graph-atlas.md](../../../graph-atlas.md), produced by focused generators and verified by `pnpm run verify-doc-graphs` / existing catalog freshness checks as part of `doc-sync`. + +The index is a relationship layer above the existing catalogs. It does not replace exact references; instead, it links to them and explains how their pieces fit together. + +### Maintenance modes + +Every graph page declares one maintenance mode: + +- **Generated**: all nodes and edges are discovered from source; `--check` fails if the committed artifact is stale. +- **Hybrid generated**: source discovers the inventory, a small manifest classifies irreducible policy, and a completeness guard fails if discovered items are unclassified. +- **Curated**: the diagram explains design intent, temporal order, or ownership; it is emitted by the generator so the graph docs remain a regenerated unit, but the content is deliberately authored. + +### First shipped index + +The first index links ten relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. + +| Graph | Maintenance mode | Source of truth | +|---|---|---| +| [module dependency graph](../../../module-graph.md) | generated | `packages/*/*/package.json` peer dependencies plus package group paths | +| [tool schema catalog and package map](../../../tool-catalog/tools.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | +| [capability seams and core services](../../../capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | +| [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [coding-agent app composition](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [acp-agent app composition](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [event producer/consumer matrix](../../../event-producer-consumer.md) | hybrid generated | Cordis event declarations, AST-scanned `ctx.on/emit/parallel/serial/waterfall` sites, and explicit dynamic dispatch overrides | +| [agent turn and step lifecycle](../../../agent-lifecycle.md) | curated | architecture.md loop lifecycle, Cordis catalog links, and session event semantics | +| [tool execution pipeline](../../../tool-execution-pipeline.md) | curated | tool pipeline semantics and the `tools/execute` waterfall | +| [ACP snapshot replay](../../../../packages/ui/acp/snapshot-replay.md) | curated | snapshot harness behavior | + +### Why generators own the docs + +Package topology stays in `gen-module-graph.ts`, and tool-package affordances stay in `gen-tool-catalog.ts`, because those generators already own the canonical facts and freshness gates. `gen-doc-graphs.ts` owns the remaining relationship pages and the index. The tradeoff is that curated diagrams are edited in TypeScript string blocks rather than directly in Markdown. That is acceptable for this first cut because the user-facing artifact is still plain Markdown/Mermaid, and a future change can split the curated pages out if authorship ergonomics matter more than regeneration. + +### Completeness guards + +The hybrid pages must fail loud when their manifests are stale: + +- The module graph reads every package's `peerDependencies` and groups each package by its `packages/<group>/<pkg>` path. +- The tool catalog boot-harvests shipped tools and renders the package/service/effect map from the same manifest that its completeness guard already checks. +- The capability seam graph imports the Cordis service collector and asserts every discovered harness `ctx.<key>` is classified in `SERVICE_ROLES`, and every classified key still exists. +- The event producer/consumer matrix labels itself hybrid because subagent lifecycle events deliberately use `ctx.events.dispatch` for per-listener containment; those dynamic edges are explicit overrides rather than invisible omissions. +- `verify-mermaid` parses every repo-authored ` ```mermaid ` fence with Mermaid's own parser, so syntax errors fail `doc-sync` locally and in CI instead of showing up as broken GitHub-rendered diagrams. + +## Alternatives considered + +Committed diagrams use Mermaid because GitHub renders it in Markdown and it adds no new docs build dependency; dense many-to-many data such as event producer/consumer relationships uses Markdown tables instead. **PlantUML, hosted diagram services, and generated SVGs** were considered and deliberately not adopted until Mermaid becomes the limiting factor. + +## Consequences + +- Maintainers get visual entry points for topology, seams, event flow, lifecycle, app composition, and snapshot behavior. +- SDK users get a path from use case to package composition instead of only bottom-up package references. +- `doc-sync` now includes `verify-doc-graphs` and `verify-mermaid`, so graph drift and Mermaid syntax errors are caught with the other doc freshness gates. +- Future fs and hooks work has a concrete place to land new complexity: fs should expand the capability docs and tool catalog, while hooks should expand the event matrix and tool execution pipeline. diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md new file mode 100644 index 0000000000..9db59e1138 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -0,0 +1,39 @@ +# RFC: JSDoc completeness gate for the cordis surface + +Status: implemented + +## Problem + +The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.<key>` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE. + +The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run. + +## Decision + +Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth). + +The contract: + +- **Events** need description prose plus a non-empty `@param` for every **payload parameter**. A payload parameter is a signature parameter that carries event data; the `this` receiver annotation and the trailing waterfall `next` are exempt — `next` is dispatch machinery whose semantics the `@mode waterfall` tag (and its structural cross-check) already owns, so restating it per event would be boilerplate. Documenting an exempt parameter anyway is allowed; only absence is checked. +- **Service classes** need class-level JSDoc, and every public method needs description prose, a non-empty `@param` per parameter, and a non-empty `@returns` unless the annotated return type is `void`/`Promise<void>` (where `@returns` stays optional — resolution timing can be worth documenting — but is never required). +- **Stale tags error**: an `@param` naming no real parameter is a violation, mirroring the `@mode`-contradicts-signature check. Tag descriptions must be non-empty; their semantic quality beyond that is review's job. +- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). +- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. + +The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. + +Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. + +## Alternatives considered + +- **An ESLint rule** — cannot see the scope's machine definition (which `interface Events` members and which `ctx.<key>` classes are the cordis surface); the catalog generator computes exactly that mapping on every run, so the gate lives there. +- **Rendering the tags into the catalog** — restructuring the services section into per-method entries was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. +- **An escape-hatch tag** — none exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. + +## Consequences + +- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. +- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. +- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. +- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. +- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md new file mode 100644 index 0000000000..5d886fa4bd --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -0,0 +1,34 @@ +# RFC: Documentation tiers, budgets, and the ceiling gate + +Status: implemented + +## Problem + +The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 50 commits in two and a half weeks — each PR appending its own lesson, none displacing anything — until the same rule was stated two or three times inside one file (the pushed-branch rewrite ban ~600 words across two sections; the with-key e2e policy ~400 words across two), an incident already recorded in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) was retold inline at ~750 words, and the per-package one-liner map existed in five places. [architecture.md](../../../architecture.md) grew the same way: paragraph walls re-narrating RFCs it already links, plus implementation-status annotations that were stale the week after they were written. The writing rules that forbid this (document current state, never history) predate the drift and sat in the very file violating them — prose rules alone do not hold against accretion pressure. The repo's standing answer to an invariant of this kind is a mechanical check ([quality gates](2026-06-11-quality-gates.md), [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)). + +## Decision + +- **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc. +- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, RFCs, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. +- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600) — the same rollout mechanism as the [translation-pairing `required` list](2026-07-02-bilingual-docs-and-pairing-gate.md). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act. +- **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../../.agents/skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md) over the i18n contract. + +## Alternatives considered + +- **Skill and review discipline without a gate** — rejected: the accretion above happened while the current-state rule and reviewer attention already existed; a prose rule with no mechanical backstop demonstrably does not hold here, and this repo's own [quality-gates stance](2026-06-11-quality-gates.md) says invariants worth keeping are worth encoding. +- **A broad gate over every doc tier** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact, e.g. `packages/ui/acp/acp-feature-support.md`) and generates per-file override churn that trains contributors to rubber-stamp raises. +- **Housing the standard inside the skill** — rejected: contracts live in docs and workflows in skills; a standard packed into SKILL.md is invisible to an agent that edits docs without invoking the skill, and `docs/AGENTS.md` already loads as subtree instructions for anyone working under `docs/`. + +## Consequences + +- Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. +- The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth. +- Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. + +## Deferred work + +The first audit cycle under the standard, in rough priority order (evidence gathered in the survey that motivated this RFC): + +- Package README trims where generated catalogs or JSDoc are restated or history is narrated: `packages/ui/acp`, `packages/core/tools`, `packages/bash/tool-bash`, `packages/core/session`, `packages/compact/compact-basic`, `packages/session-persistence/session-persistence`. +- `docs/core-data-structures/core.md`: drop the JSDoc walls from the `Agent`/`GenerateOptions` type-equiv pastes per that page's own stated rule. +- [Postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md): merge the overlapping Executive summary and Summary sections. diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md new file mode 100644 index 0000000000..d854991ad9 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md @@ -0,0 +1,32 @@ +# RFC: Generate the RFC index tables + +Status: implemented + +## Problem + +The RFC index's per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do. + +## Decision + +Keep the curated prose; generate the list. The tables live in [`docs/rfc/INDEX.md`](../../INDEX.md), a **fully generated file** — the curated prose stays in README.md, which carries no index rows at all. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it: + +- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites INDEX.md in full from the tree. +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure, asserts the committed INDEX.md byte-matches a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern — and rejects an index-shaped row in the curated README. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed. + +Adding, moving, or deleting an RFC means editing only the RFC file and running the generator; the classification RFC's rejected-alternatives record carries the supersession cross-link. + +## Alternatives considered + +### Why not marker-delimited regions inside README.md? + +The first landed shape: the generator spliced the tables into README.md between `gen-rfc-index` marker comments, under each `## {Lifecycle}` heading. Superseded by the whole-file INDEX.md once the README also absorbed the in-file format contract ([the uniform-format RFC](2026-07-05-uniform-rfc-format.md)): a front-door README hosting hundreds of generated rows dwarfed its curated prose, and splice mechanics (marker pairs, heading checks, outside-region row detection) exist only to protect curated text that a dedicated generated file simply doesn't contain. + +### Why not the verifier-only model? + +It catches mistakes but still makes every proposal edit a shared hotspot in a hand-maintained table, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts. + +## Consequences + +- The generated file is explicit: its banner names the generator, there is no curated region to protect inside it, and the generator refuses to run on a structurally invalid tree. +- A malformed or missing H1 is a hard error in both the generator and the gate — the H1 is now load-bearing as the index title source. +- Concurrent RFC branches resolve index conflicts by rerunning the generator, never by hand-merging rows. diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md new file mode 100644 index 0000000000..9bb7b7d624 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -0,0 +1,34 @@ +# RFC: Generated persistence log event catalog + +Status: implemented + +## Problem + +The session event log is the harness's on-disk contract: every `SessionEventMap` member is a record a persistence backend writes verbatim and a replay reconstructs from, and adding one that breaks the durability rules is a breaking change to the on-disk format. Yet the vocabulary had no single reference. The declarations are split across three files — the owning interface in `@deepseek-ai/dsh-session` plus declaration merges in `@deepseek-ai/dsh-compact` and `@deepseek-ai/dsh-hook-protocol` — and the doc surfaces covered it with hand-copies: a `hook/*` payload table in [session.md](../../../core-data-structures/session.md), a `compact/*` payload table in the compact README, payload bullets in the hook-protocol README, and a name-list in the session README. The name-list's merge note had already drifted (it named the compaction merge and omitted the hook merge entirely), and nothing could catch the next merge going undocumented: a hand-copy only checks the names someone already wrote down. This is the same gap the [cordis catalog](2026-06-20-generated-cordis-catalog.md) closed for bus events and the [tool catalog](2026-07-02-tool-schema-catalog.md) closed for model-facing tools — and log events are covered by neither: a `SessionEventMap` member is not a cordis `Events` declaration (it reaches listeners via the single `session/event` emit), so it has no cordis-catalog row by design. + +## Decision + +Generate `docs/persistence-catalog/log-events.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools). + +`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` — log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope. + +Specific choices: + +- **JSDoc completeness, enforced.** Every member must carry description prose — the JSDoc becomes the catalog entry, the same forcing function the cordis catalog applies to bus events. An `@mode` tag on a member is a hard error: dispatch modes belong to cordis bus events, and a log event has none — the tag would misread as "this fires on the bus with mode X". Violations aggregate into one error listing every offender. +- **The surface badge is derived, not hand-listed.** `SurfaceEventType` — the subset that produces LLM messages and may carry `surfaceOp` — is parsed from its union declaration in the owning package; a union member naming no declared event is a hard error (a stale union member would otherwise silently badge nothing). Everything else renders **log-only**. +- **A dedicated fence.** Payload blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (a bare payload fragment is not standalone-compilable). +- **Repo scope.** The catalog enumerates the packages in this repo, matching the siblings' packages-only scope; a downstream plugin can merge further event types, which are outside the catalog by construction. The walk defends its own assumptions with hard errors: the owning top-level `interface SessionEventMap` must be the single exported declaration in `@deepseek-ai/dsh-session` (an unrelated, local, or duplicate same-named interface cannot be catalogued as the on-disk vocabulary), no declaration may carry `extends` (inherited keys would join `keyof SessionEventMap` without a catalog row), every member must be a property signature with an explicit payload type (a method-form member would join `keyof` yet slip past a silent walk), and a duplicate member across declarations fails. + +This supersedes the hand-copies: the session.md `hook/*` table, the compact README's event table, the hook-protocol README's payload bullets, and the session README's name-list now link the catalog instead of restating payloads (the surrounding semantics prose stays where it was). The two stray `@mode emit` tags on the hook-protocol merge members are removed — the new gate rejects them as the category error they were. + +## Alternatives considered + +- **A boot-based generator, like the tool catalog's** — the log vocabulary is fully static, so the AST pass reads the whole truth without booting anything. +- **Keeping the hand-copies** — a hand-copy only checks the names someone already wrote down; the session README's merge note had already drifted when the catalog landed. + +## Consequences + +- The catalog cannot drift: a vocabulary change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. +- Event prose has a single home, the JSDoc at the declaration; thin JSDoc yields a thin catalog entry, pressuring authors to document at the source. +- The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler. +- The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change. diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md new file mode 100644 index 0000000000..696d46cb20 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -0,0 +1,28 @@ +# RFC: One gated in-file format for RFCs + +Status: implemented + +## Problem + +The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some twenty-seven `Status:` line spellings once free-text rejection reasons are collapsed — bare enums, dated parentheticals duplicating what the filename and git already carry — plus three English files (and the zh counterpart of one of them) with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and nineteen implemented RFCs still carrying thirty occurrences of the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`, `Proposal`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires. + +## Decision + +[README.md § The file format](../../README.md#the-file-format) is the in-file contract — the header block (`# RFC: <title>` plus a dateless, folder-agreeing `Status:` enum whose only content is the rejection reason), the per-lifecycle body skeleton (`Problem` opener everywhere; `Proposal`/`Acceptance criteria`/`Risks` in `proposed/`; present-tense `Decision`/`Consequences` with proposal-era headings banned in `implemented/`; frozen proposal shape in `rejected/`), a mandatory `Alternatives considered` section, and the canonical section vocabulary between which bespoke technical sections stay free-form. `pnpm run verify-rfc-format` ([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts)) enforces every mechanical clause as part of `doc-sync`, so a lifecycle move that skips its rewrite now fails CI instead of review memory. + +The whole corpus was normalized in the same change that defined the format — the pre-release stance: no transition period, no dual-format tolerance. The one grandfather is content, not format: alternatives are recorded, never invented, so a pre-format RFC whose alternatives are not reconstructible from the record carries the exact `rfc-format: alternatives-not-recorded` comment, which the gate accepts only for files dated before this RFC. + +## Alternatives considered + +- **A full rigid template** (one fixed section sequence per lifecycle, every RFC restructured to fit) — rejected: the big design RFCs carry eight to fifteen bespoke technical sections (package topology, wire contracts, schemas) that are load-bearing content, not drift; a rigid sequence would force destructive rewrites now and template-fighting forever. +- **Header-only normalization** (H1 and Status, bodies untouched) — rejected: the debt markers flagged the *body* genre split, and leaving `Context`/`Decision` beside `Problem`/`Proposal` indefinitely resolves nothing. +- **No Status line** (the folder already is the status; the three newest pre-format RFCs (and the zh counterpart of one) omitted the line) — rejected in favor of keeping a self-describing file: the drift risk that motivated dropping it is neutralized by gating the line against the folder instead. +- **Dated status** (`Status: implemented (accepted YYYY-MM-DD)`) — rejected: the acceptance date is narrated history the writing rules keep out of docs; the filename carries first-proposed, git carries the rest, and the gate could check a date's format but never its truth. +- **A bare `# <title>` H1** — rejected: the `RFC: ` prefix is the corpus-majority form and self-describes the genre when a file is read outside its tree; the index generator strips it, so index rows are identical either way. +- **`## What we give up` as the implemented closer** (the README's own phrase for what an RFC records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. +- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. +- **A standalone `FORMAT.md` contract file** — the first landed home; folded into README.md once the generated index moved out to [INDEX.md](../../INDEX.md): with the tables gone the README regained the room, and one front door carrying layout, classification, and format beats splitting the contract across two files. + +## Consequences + +Every RFC now costs slightly more structure, and the mandatory `Alternatives considered` section is deliberate friction: a decision recorded without what it beat invites the re-litigation RFCs exist to prevent. Pre-format RFCs whose alternatives were not reconstructible carry the grandfather comment permanently — an honest gap on the record rather than fabricated rationale. `doc-sync` gains one gate, and moving an RFC between lifecycle folders is now real work at move time (the body rewrite the move always owed) instead of deferred cleanup nothing tracked. The thirty-nine debt markers are gone, resolved by the template they were waiting for. diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 24f693e6b6..54f8397fc9 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -1,8 +1,8 @@ # RFC: Drop the mutable session summary -Status: implemented (proposed and accepted 2026-06-19) +Status: implemented -## Context +## Problem 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. @@ -26,6 +26,8 @@ This is recorded as a decision because it is **durable** (it narrows a public se 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 +## Consequences 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 4ec39bcce4..0a80ec5a41 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 @@ -1,6 +1,6 @@ # RFC: Fold trace-only session facts into load-bearing events -Status: implemented (proposed and accepted 2026-06-20) +Status: implemented ## Problem @@ -8,27 +8,26 @@ The session event vocabulary includes first-class events that are not part of re 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 +## Decision -Remove standalone trace-only events only where their information can be preserved without a parallel record: +Standalone trace-only events are removed exactly where their information is preserved without a parallel record: -- 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`. +- Successful-step usage folds into the matching `assistant/message` (`assistant/message { turn, step, content, usage? }`), so the assembled model output and its accounting travel together. +- A failed or aborted step that has usage but no assistant content carries the usage on an empty-content `assistant/message` (the implementation note below carries the no-information-loss proof) — no persisted usage chunk goes unrepresented. +- The step number from the standalone `error` event folds into `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — `turn/end` is the durable turn outcome ACP and resume already consume. +- `agent/error` and logging stay for live diagnostics; there is no 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. +The user conversation log contains what is needed to render, resume, audit, and account for the interaction without consumers reconciling duplicate trace rows. -## Acceptance criteria +## Alternatives considered -- `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 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. -- 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. +**Keep the standalone rows as telemetry** — the events made the canonical transcript look more useful as telemetry than it was, at the cost of event variants, invariants, tests, snapshots, and persistence cases nothing consumed. If analytics become real, the shape is a projection helper or a dedicated telemetry store with its own retention policy — not duplicate trace rows in the conversation log. -## What we give up +## Verification + +`SessionEventMap` carries no standalone `usage` or `error`; the loop appends no separate usage event and records durable failures through `turn/end { kind: 'error', step, message, code? }`; ACP snapshots and persistence tests assert no trace-only lines; recorded fixtures are on the new event shape with the session format version pinned at `0` (backends reject any non-`0` stored log per the pre-release format policy); and the docs state where token usage and operational errors are observed. + +## Consequences 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/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 e35e090dbf..ab921dd62c 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 @@ -1,6 +1,6 @@ # RFC: Drop the unconsumed `llm/adapter-change` event -Status: implemented (proposed and accepted 2026-06-20) +Status: implemented ## Problem @@ -10,32 +10,23 @@ This differs from `tools/change` and `system-prompt/change`. Those two events ar 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 +## Decision -Remove only `llm/adapter-change`: +Only `llm/adapter-change` is removed: the declaration in `dsh-llm`'s `interface Events`, the `ctx.emit('llm/adapter-change')` calls, and the "Emits `llm/adapter-change` on registration and disposal" sentence in `LlmService.registerAdapter`'s JSDoc. `registerAdapter()`'s effect generator keeps the mutation and rollback disposer for HMR/disposal but sheds the listener-throw rollback ordering that existed only for the removed event. The adapter-disposer test asserts the returned disposer removes the adapter without subscribing to the event; the listener-throw rollback test is gone with its subject. The event taxonomy in [docs/architecture.md](../../../architecture.md) and [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) is updated in the same 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](../../../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. +## Alternatives considered -## Why not remove every registry change event? +### 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 +## Verification -- `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. -- No production code path changes observable behavior (verified by unchanged ACP snapshot goldens and the echo-agent smoke test). +`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot goldens and the echo-agent smoke are byte-unchanged. -## Risks +## Consequences - **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. diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 74d37bf75a..585c611262 100644 --- a/docs/rfc/implemented/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: implemented (proposed and accepted 2026-06-20) +Status: implemented ## Problem @@ -16,27 +16,19 @@ This is the [drop-mutable-session-summary](../../implemented/simplification/2026 `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 +## Decision -Make `stream()` the only public LLM call surface: +`stream()` is the only public LLM call surface. Removed with their JSDoc and doc references: `LlmService.streamBlocks()`; `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult`; `BlockAssembler.flushReady()`/`flushRemaining()` and the `flushed` cursor field; and `BlockAssembler.result()`, which only served the deleted `generate()` path. Adapter tests drive `ctx.llm.stream()` through a small helper that pushes chunks into `BlockAssembler` and returns the assembled message, usage, and finish reason — keeping the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact without a public method whose only callers are tests. The assembler invariants that apply to `push()` / `blocks()` / `message()` keep their tests; the flush-API pins went with the API. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) is `stream()` only, the event taxonomy carries no `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without the removed convenience methods. -- 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/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](../../../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. +## Alternatives considered -## Acceptance criteria +**Keep `generate()` as a test-only convenience** — rejected: adapter tests hand-draining `stream()` through the shared assembler exercise the same streaming path production uses, and a public method whose only callers are tests is exactly the dead-surface shape [the drop-mutable-summary precedent](2026-06-19-drop-mutable-session-summary.md) retired. A future consumer that wants assembled blocks without deltas reintroduces a focused helper with that consumer. -- `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/llm/README.md`, [docs/architecture.md](../../../architecture.md), and module docs no longer mention the removed convenience surfaces. +## Verification -## Risks +`streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone required are gone with no new dead exports; both real adapters are exercised through `stream()` and the shared assembler; the loop behaves identically (ACP snapshot goldens unchanged); and the README, architecture doc, and module docs carry no mention of the removed surfaces. + +## Consequences - **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. 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 9a385a01a9..765cadd573 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,6 +1,6 @@ # RFC: Prune dead methods from the persistence seam -Status: implemented (proposed and accepted 2026-06-20) +Status: implemented > **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. @@ -14,27 +14,26 @@ The abstract service declared its operations beyond create/append: `load`, `list `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 +## Decision -Remove the methods nothing consumes, from the abstract seam, the implementation, and the contract/spec suites that exist only to exercise them: +The methods nothing consumes are removed — from the abstract seam, the implementation, and the contract/spec suites that existed 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 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. +- `SessionPersistence.has()` / `.delete()` are gone: the abstract declarations, the coordinator's `has`/`delete`/`deleteCore`, and the `PersistenceBackend.deleteStored` hook (jsonl + sqlite each implemented `deleteStored` only to satisfy the hook — those implementations went too). The backends are the [dual-backend](../../implemented/architecture/2026-06-14-session-persistence.md) design and otherwise out of scope; removing a hook they implemented for no consumer is part of removing the hook, not a backend redesign. +- Every doc and source-comment reference is updated to the surviving four-method, `list()`-only contract — not only literal `has(`/`delete(`/`deleteStored` spellings but `{@link has}`/`{@link delete}` JSDoc links and "six public methods" counts — across the seam and backend READMEs, [docs/architecture.md](../../../architecture.md), the [session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFCs, and the coordinator/backends JSDoc. -## Why not keep them as "the seam should be complete"? +## Alternatives considered + +### Why not keep them as "the seam should be complete"? 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 +## Verification -- `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). -- The persistence seam README and `docs/architecture.md` no longer list the removed `has`/`delete` methods. +`has`/`delete`/`deleteStored` are gone from the persistence seam, impl, and contract suites with no new dead exports; the remaining operations (`create`/`append`/`load`/`list`) are untouched, with ACP `session/list` and crash-recovery behaving identically; and the seam README and `docs/architecture.md` list only the surviving methods. -## Risks +## Consequences - **`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. - **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. 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 67fe9b07fc..b97d0abcee 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 @@ -1,8 +1,8 @@ # RFC: Keep one public stop primitive -Status: implemented (proposed 2026-06-20; accepted in amended form — `whenIdle()` retained) +Status: implemented -> **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. +> **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 [the defensive patterns](../../../defensive-patterns.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 @@ -12,22 +12,23 @@ The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prom 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 +## Decision -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. +`cancel()` is 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. 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. +Public `abort()` is deleted, with the tests that exercised it as standalone API and the docs that described step-only abort as an embedding feature. Empty-queue abort tests migrated to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` drive that controller directly via an in-package typed cast to the private field; tests that only pinned the removed no-arg `abort()` default went with the method. The disposer remains async and still waits for the loop to stop. -## Acceptance criteria +## Alternatives considered -- `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. +**Removing `whenIdle()` too** — the original proposal's shape, reversed on validating the premise against the code (the implementation note above carries the full record): it is a load-bearing quiescence primitive, and pushing consumers onto hand-observed `running`→`idle` transitions is exactly the brittle path the defensive patterns warn against. -## What we give up +## Verification + +`Agent` exposes no public `abort()` while `cancel()`, `whenIdle()`, and `steer()` remain; ACP cancellation calls `cancel()`; teardown awaits quiescence through handle disposal, with `whenIdle()` resolving on quiescence for non-owner observers; and the suites cover cancellation and disposal as the two supported stop paths. + +## Consequences 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. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md new file mode 100644 index 0000000000..e4b72ab5d1 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -0,0 +1,45 @@ +# RFC: Stop mirroring durable boundaries as agent events + +Status: implemented + +<!-- Shipped in AMENDED, narrowed form: the four turn/step BOUNDARY mirrors are + removed; `agent/steering` and `agent/stream-chunk` were RETAINED here (they + are not durable-boundary mirrors — see "Scope: what is and isn't removed"). + The original proposal bundled `agent/steering` into the removal; keeping it + out kept this RFC's scope to boundaries. Each retained event was later + removed by its own decision — see + [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md) + and [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). --> + +## Problem + +The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. 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 was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. + +This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made 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. + +## Decision + +Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. + +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[<agent> turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. + +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. + +## Scope: what is and isn't removed + +Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. + +RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: + +- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). +- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). +- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. + +## Alternatives considered + +- **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). +- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` + the id map instead. + +## Consequences + +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md new file mode 100644 index 0000000000..2663637859 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -0,0 +1,128 @@ +# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin + +Status: implemented + +## Problem + +The filesystem capability from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) currently makes one abstract `FileSystem` service own two different jobs: + +1. **Provider operations** — resolving targets, stat/version metadata, text reads/streams, atomic writes, and guarded literal edits. +2. **Agent-facing policy** — line windows, literal edit semantics, and read-before-write/edit observed-state. + +That makes every future backend reimplement model-facing read semantics and observation policy. `readPage` returns numbered lines and view metadata; the base service stores per-owner file state and distinguishes `full` from `partial` reads. Those are useful policies, but they are not filesystem-provider primitives. Literal text mutation is different: version guard, literal match, ambiguity detection, and atomic rewrite must stay together inside the provider mutation boundary, but the current `applyEdit` name and surrounding seam tie that provider operation to the old read-before-edit policy shape. + +This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read. + +The old RFC already deferred a separate `@deepseek-ai/dsh-fs-policy` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec. + +## Decision + +Split the stack into four layers: + +```text +tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events) +policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard) +provider dsh-fs-local local implementation of ctx.fs +``` + +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It is the executor: it injects `fs` (not a policy service) and reaches `ctx.fs` directly, owns read windowing, and dispatches the `fs/*` events so `dsh-fs-policy` can gate and record. + +This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-fs-policy` is a gate PLUGIN that participates through the `fs/*` events rather than a `ctx.fileContext` method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in `dsh-tool-fs`. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider). + +## Provider Contract + +`@deepseek-ai/dsh-fs` shrinks to provider text IO plus guarded text mutation: + +```ts ignore-check +abstract resolve(path: string): Promise<FsTarget> +abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> +abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string> +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> +abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> + +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} + +type FsWriteIntent = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +`stat` returns metadata, not content. `version` is the freshness token; `type` lets the executor reject directories/special files before reading; `size` lets the `read` tool choose `readText` vs `streamText` without probing by failure. `undefined` means absent. + +`readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`. + +`writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`. + +`editText` is a provider-level guarded text mutation. When guarded it first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it. + +This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down. + +Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, `FsReadRequest`, `FsTextLine`, line/window constants, `formatReadBody`, and the observed-state `WeakMap`. `applyEdit` is replaced by the narrower provider primitive `editText`, whose contract is version-guarded literal text mutation rather than policy-layer read authorization. The `FS_PARTIAL_OBSERVATION` code also leaves the `FsErrorCode` taxonomy: freshness authorization has no partial/full distinction, so nothing can raise it. `FsTargetKey` and `FsVersion` become branded opaque ids under the existing [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md). + +## Policy Contract + +`@deepseek-ai/dsh-fs-policy` is a plugin, not a service: it registers no `ctx.*` key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the `fs/*` event gate the executor dispatches. (This RFC originally proposed a concrete `ctx.fileContext` service with `read`/`write`/`edit` methods; [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) refined it into the plugin described here so the tool is never method-coupled to the policy.) + +Observed state lives here as `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists iff the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence *is* the prior-observation record — there is no separate `hasRead` flag. The owner is derived structurally from the opaque event actor (`{ agent?: { session? } }`), a shape that lives in `dsh-fs-policy`, not `dsh-fs`. + +The plugin decides three `fs/*` events: + +- `fs/write-intent` — no prior observation ⇒ `{ kind: 'createIfAbsent' }` (only new files can be created blindly); a prior observation ⇒ `{ kind: 'replaceIfVersion', version: vObserved }` (existing files replaced only if unchanged since the observation). Single-slot decision; does not call `next()`. +- `fs/edit-intent` — requires a prior observation by the owner (else `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale. +- `fs/observed` — records `{ version }` for this owner+target after a successful read/write/edit. Synchronous, side-effect-only `WeakMap.set`. + +The plugin does NO filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — the plugin only supplies `vObserved` as the basis. + +## Tool Contract + +`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. It is the executor: it validates model args, reads/writes/edits through `ctx.fs` directly, owns line windowing and result rendering (`N: text`, footer, `<path>/<content>` envelope), and dispatches the `fs/*` events. + +Each mutation dispatches its intent waterfall with an `undefined` bare-provider default, then calls `ctx.fs`, then emits `fs/observed`: e.g. `write` does `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`. A `read` stats once, reads/streams, builds the window, and emits `fs/observed`. Passing `exec` as the actor lets `dsh-fs-policy` derive the owner without the tool reaching into the policy. + +Because the policy is contributed through events with an `undefined` default, `dsh-tool-fs` is not method-coupled to `dsh-fs-policy`: with the plugin absent, every intent waterfall falls through to `undefined` (unconditional bare-provider write/edit) and `fs/observed` has no listener. Loading the plugin back layers the read-before-write/edit policy on. + +## Concurrency Boundary + +In-process updates are safe: the local backend keeps the existing per-target mutation lock, so version-check-then-rename is serialized and a losing update sees `FS_STALE_VERSION`. + +In-process creates are guarded by the same per-target mutation lock: two callers racing with `createIfAbsent` serialize, one creates, and the next sees the target exists and receives `FS_NOT_OBSERVED`. Cross-process creates are best-effort only; a local stat-then-rename guard cannot make portable create-exclusive guarantees across all future backends. + +Cross-process writes are best-effort freshness plus atomic replacement: `mtime:size` usually catches editor saves, but same-tick same-size writes can miss; atomic temp+rename prevents torn files but not every lost update. + +## Supersedes + +This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third: + +- Read-before-write/edit policy moves out of `ctx.fs` and into the `dsh-fs-policy` plugin (on the `fs/*` event gate). +- Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged. +- Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section. + +It keeps the interface/implementation/consumer discipline, consumer-never-imports-backend rule, backend-defined target/version/display metadata, atomic local writes, and the shared `FsError` taxonomy. + +## Verification + +`dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText` (`stat` returning `FsInfo | undefined`, `writeText` taking `FsWriteIntent`), with the removed types/primitives gone; `dsh-fs-local` carries no line, view, or `formatReadBody` logic; model-facing schemas stayed byte-for-byte unchanged. Tests pin that a windowed read authorizes a later edit of an unchanged file, that an edit based on a stale read reports `FS_STALE_VERSION` before attempting literal matching, that version-CAS behavior is preserved, and that the observation contract holds (a `read`-tool read records observed-state; a direct `ctx.fs` read does not); `dsh-fs-policy` has HMR/disposal coverage. + +## Later extension + +The seam was later extended with direct directory listing by [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md). That follow-up is tracked separately so this RFC's acceptance criteria continue to describe the fsspec-style refit that originally shipped. + +## Alternatives considered + +- **Byte-level fsspec (`cat`/`open` handing back raw bytes)** — rejected: the seam is deliberately text-storage, half a level up, so UTF-8 decoding, binary/NUL rejection, and guarded text mutations live once in the provider and the policy layer never touches raw bytes or separates stale checks from the mutation critical section. +- **A concrete `ctx.fileContext` method service** — this RFC's original policy shape; reworked by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) into the gate plugin, so the tool is never method-coupled to the policy. +- **Keeping `readPage` and `full`/`partial` view authorization on the provider** — the pre-refit shape the Supersedes section reverses: view completeness is not what edit safety needs, version freshness is, and the view rule made large files past the read cap impossible to edit. + +## Consequences + +- Adds a fourth fs package and a new plugin layer. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam. +- Direct `ctx.fs` use bypasses the policy: a direct `ctx.fs.readText` emits no `fs/observed`, so under the default policy a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through the `read` tool. The failure is explicit and documented. +- Large-file line windowing moves from the backend to the `read` tool in `dsh-tool-fs`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. +- Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite. +- Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance still discourages blind full replaces. diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md new file mode 100644 index 0000000000..a272dfe0b2 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -0,0 +1,45 @@ +# RFC: Stop mirroring the token stream as an agent event + +Status: implemented + +## Problem + +The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart: + +```ts ignore-check +const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) +chunkSeqs.push(chunkEvent.seq) +ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror +``` + +- Durable: `assistant/chunk: { turn, step, chunk }`. +- Live emit: `agent/stream-chunk(agent, turn, step, chunk)` — same `StreamChunk`, same `turn`/`step`. + +The only thing the emit added over the session event was the live `Agent` handle, and the sole consumer discarded it (its handler signature was `(_agent, _turn, _step, chunk)`). + +This is the same duplication the [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) eliminated for turn/step boundaries: a consumer had two sources of truth for one durable fact, and every change had to touch both. That RFC deferred the chunk stream ("`assistant/chunk` persistence remains load-bearing, so the chunk stream could later be evaluated as a mirror, but that is a separate decision") rather than bundling it in. This RFC is that separate decision. + +The premise the deferral hinged on is settled: chunk persistence is authoritative and staying. The proposal to stop persisting chunks and keep only a transient live stream event was [rejected](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) — high-fidelity replay, partial failed streams, and snapshot replay all depend on the persisted `assistant/chunk` feed. So `assistant/chunk` on `session/event` is the durable, load-bearing token stream, and `agent/stream-chunk` is a pure redundant mirror of it. + +## Decision + +Remove `agent/stream-chunk` from the agent event taxonomy. The token stream is read off `session/event` as `assistant/chunk`, the same feed persistence and replay already use — `session/event` is the single live transcript stream (assistant chunks, turn/step boundaries, tool activity, todos). + +**Consumers.** The only production consumer that mattered — the ACP bridge (`dsh-acp`), the real editor-facing streaming surface — already renders `assistant/chunk` off `session/event`, never `agent/stream-chunk`, so it is unaffected. The stdio UI (`dsh-ui-stdio`, a disposable test REPL) was the sole live consumer; it already had a `session/event` listener (from the boundary migration), so its chunk rendering folded into that listener as an `assistant/chunk` case. Consolidating to one listener also removed a latent hazard: the `inReasoning` dim-SGR flag was previously shared across two separate listeners (`agent/stream-chunk` and `session/event`), so a chunk and a boundary racing on it had no defined order; a single listener over the append order makes the interleaving deterministic. + +## Scope + +Removed: `agent/stream-chunk`. + +Not touched: +- `assistant/chunk` (the durable session event) — the authoritative token stream, kept exactly as-is. This RFC removes the LIVE MIRROR, not the persistence (the persistence-removal proposal was separately rejected — see above). +- `agent/steering` — not touched by THIS decision (a control signal, not the token stream). Its durable twin is `steering/message`, and the mirror emit was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). +- `agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/session-start` — lifecycle/control events that are not transcript data and have no durable duplicate. + +## Alternatives considered + +**Remove the persistence and keep only a transient live stream** — the inverse cut, [rejected separately](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md): high-fidelity replay, partial failed streams, and snapshot replay all depend on the persisted `assistant/chunk` feed. With that settled, the live emit is the redundant half of the pair. + +## Consequences + +A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event` and filters `assistant/chunk` (the `Agent` handle, if needed, is recovered from a session-id→agent map built from `agent/created`/`agent/disposed`, exactly as boundary consumers already do). No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md new file mode 100644 index 0000000000..0e999c629f --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -0,0 +1,27 @@ +# RFC: Drop the `image` content block until a path can honor it + +Status: implemented + +## Problem + +`ImageBlock` (`packages/llm/llm/src/types.ts`) had no production producer, and every consumer on every path DROPPED it: the deepseek adapter's serializer skipped image blocks (a documented MVP limitation), the pi-ai converter skipped them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwarded image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charged a flat token constant and rendered `[image]`. An `ImageBlock` constructed then would silently vanish from the wire — the vocabulary advertised a capability no path honored, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere were tests pinning the skip/drop/estimate branches. + +## Decision + +Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. + +## Alternatives considered + +### Why not keep it? + +This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. + +The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. + +## Verification + +No `ImageBlock` / harness `type: 'image'` block is constructed anywhere outside RFC records; the codec's inbound ACP-image rejection keeps its tests; and the adapter/codec/compaction switches handle the case through their unknown-block default arms, pinned by the plugin-added-block tests. + +## Consequences + +Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md new file mode 100644 index 0000000000..95a5a487b5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -0,0 +1,33 @@ +# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path + +Status: implemented + +## Problem + +Two request-contract knobs rode the whole request pipeline, yet neither could do anything: + +- **`prefill`** (`packages/llm/llm/src/types.ts`) had no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters rejected it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each threw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior was two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. +- **`strict`** (`ToolSchema`, same file) was threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note recorded that strict mode requires the `/beta` base URL the adapter does not use), a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`, and a conditional `Strict:` row in the tool-catalog renderer (`scripts/gen-tool-catalog.ts`). No shipped tool set it — `rg` across every `tool-*` package src and `examples/` found zero `strict:` producers; the only setters were dsh-tools unit tests. + +Both knobs were adapter-symmetric, so removal shed them from both twins together — the [twin-adapter design](../architecture/2026-06-13-twin-llm-adapters.md) is untouched. + +## Decision + +- `prefill` is removed from `GenerateOptions`, along with both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste line in [core.md](../../../core-data-structures/core.md), and the adapter README rows documenting the rejection. The cookbook's UNSUPPORTED guidance ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)) states the rule generically — a `GenerateOptions` field your provider cannot honor throws `LlmError(..., 'UNSUPPORTED')` — instead of using prefill as the example. The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record prefill as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). +- `strict` is removed from `ToolSchema`, `DefineToolOptions`, `defineTool`, the `schemas()` allowlist, the deepseek serializer branch and its wire-type field, and the tool-catalog renderer's `Strict:` row. The pi-ai payload fixup is simplified to the unconditional scrub of pi-ai's own per-tool strict default (pi-ai stamps `strict: false` on every serialized tool; the hand-rolled twin sends no such field, so the scrub survives for wire parity, pinned by its serializer test). The setter tests and the core.md paste line are gone; both `GenerateOptions` and `ToolSchema` keep their rows in `scripts/type-equiv.manifest.json`, since each type survives minus a field. + +This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. + +## Alternatives considered + +### Why not keep them? + +"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. + +## Verification + +`rg prefill` returns only RFC records (this one and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s producer-gated consequence); a tool-schema-scoped `rg strict` returns only this RFC, the surviving pi-ai scrub, and unrelated prose such as `strictEqual`. Both adapters' contract tests pass without the guards, and the pi-ai fixup still scrubs the library's strict default — wire parity pinned by its serializer tests. + +## Consequences + +The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) reaches for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md new file mode 100644 index 0000000000..b6fbded6ac --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -0,0 +1,32 @@ +# RFC: Drop the unconsumed web observation surface — the `providers-change` event and the status methods + +Status: implemented + +## Problem + +`WebService` exposes an observation surface no production code observes: + +- **`web/providers-change`** (`packages/web/web/src/index.ts`) is declared and emitted on every provider registration and disposal, and each registration effect's rollback yield is ordered BEFORE the emit solely so a throwing change listener unwinds the registration. No listener exists outside the package's own two unit tests (one of which exists to pin that rollback ordering). +- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) claims the tool "reads only the aggregated `searchStatus()`/`fetchStatus()`" — drift that survives only because nothing checks prose against call sites. + +The seam's own design starves both surfaces of consumers: tool registration follows product ENABLEMENT, not provider availability (`packages/web/tool-web/src/index.ts`), and provider selection resolves at execution time, never cached — so there is no cache to invalidate, no registration set to recompute, and no caller that needs an availability probe distinct from executing and routing the structured error. HMR cleanup is carried by the effect disposers themselves. + +This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md), which removed the same notification shape, the same rollback-before-emit machinery, and the same listener-throw test from `LlmService`. That RFC's keep/cut criterion — keep `tools/change` for its plausible user-facing tool-list consumer, cut the boot-time backend-registry signal — puts a web-provider registry squarely on the cut side; the status methods are the same judgment applied to a pull surface instead of a push one. + +## Decision + +The event declaration, both emits, and the rollback-before-emit ordering are deleted (the plain `ctx.effect` disposer carries HMR cleanup). `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` are deleted — the provider-private `status()` stays, since it feeds execution-time selection. The listener-throw rollback test that existed solely for the removed event is gone, and the emission assertions and every status-based assertion are rewritten onto the behavior a real caller observes: a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets. The cordis catalog is regenerated; `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md) describe the shipped contract; the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) are amended per [implemented/AGENTS.md](../AGENTS.md). + +## Alternatives considered + +### Why not keep it? + +The web seam RFC specified both deliberately — the event as a minimal HMR-visibility signal, the status methods as the tool's aggregated diagnostics — and a future provider-status panel is imaginable. But the same RFC's other choices starved them: derived-on-call selection and enablement-based registration leave no consumer that CAN need either, the shipped tool demonstrates the real pattern (execute and route the structured error), and the drifted README sentence shows the promised consumer never materialized. Per AGENTS.md "RFCs are proposals, not golden truth", these are the parts of that proposal the code has since shown to over-reach; a future observer reintroduces the smallest signal or query it actually consumes, shaped by that consumer. + +## Verification + +No `providers-change`, `searchStatus`, `fetchStatus`, or `WebCapabilityStatus` spelling survives outside RFC history; the catalog is fresh (`verify-cordis-catalog` green); registration/disposal HMR-safety tests prove cleanup through execution behavior; and the tool-web README plus the architecture paragraph describe the execution-time error-routing contract the tool actually has. + +## Consequences + +A future provider-picker UI or diagnostics panel that wants change notifications or a status query re-adds the smallest surface it consumes; the identical judgment, and its reversal condition, is already recorded on the llm precedent. diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md new file mode 100644 index 0000000000..38ec9ee4fa --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -0,0 +1,26 @@ +# RFC: Fold the stdio UI helper into the stdio app + +Status: implemented + +## Problem + +The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-agent`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. + +The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. + +## Decision + +The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the app's export SHAPE is pinned by the stdio-agent unit suite's explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). + +The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. + +## Alternatives considered + +### Why not promote it to `ui/` instead? + +Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is the product protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract. + +## Consequences + +- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos. +- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse. diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md new file mode 100644 index 0000000000..5d40b166bd --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -0,0 +1,31 @@ +# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) + +Status: implemented + +## Problem + +The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: + +- **`CacheHint` and its `cache?: CacheHint` block fields** on `TextBlock`/`ToolResultBlock` (`packages/llm/llm/src/types.ts`; the image block carried a third such field, which left with it — see [the drop-image RFC](2026-07-04-drop-image-content-block.md)). Nothing constructed a block with `cache:` anywhere — src, tests, and doc pastes all came up empty — and neither adapter read `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This was Anthropic-style `cache_control` surface with no provider that could honor it. +- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. +- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer was one hand-built test fixture needing an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`), which an `injection` trigger serves equally; the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. + +## Decision + +`CacheHint`, its `cache?` block fields, the `agent` message-source variant, and the `continuation` turn-trigger variant are deleted: the shipped vocabulary carries none of them. The llm-replay fixture uses an `injection` trigger (any non-`message` trigger serves its purpose). The type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) match the pruned maps — both symbols keep their rows in `scripts/type-equiv.manifest.json`, since each map survives minus a member — and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record cache hints as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). + +Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. + +## Alternatives considered + +### Why not keep them? + +The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) listed "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. + +## Verification + +`rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only RFC records (this one, and [the drop-image RFC](2026-07-04-drop-image-content-block.md)'s account of the image block's own `cache` field); the llm-replay fixture asserts the same replay behavior with an `injection` trigger; the core-data-structures pastes and the type-equiv manifest are in sync. + +## Consequences + +Nothing operational changed — nothing could construct these values. The mirror-event removals ([the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md), [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. The image block's own `cache?` field belongs to [the drop-image RFC](2026-07-04-drop-image-content-block.md), which removed it together with the block; this RFC covers the two fields on the block types that remain. diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md new file mode 100644 index 0000000000..0fc11a1c17 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -0,0 +1,30 @@ +# RFC: Prune write-only fields and a dead routing knob from the fs seam + +Status: implemented + +## Problem + +The [fs seam split](2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody: + +1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** — *removed ahead of this change by the no-hardcoded-tunables audit, which made the routing bound `dsh-tool-fs`'s `readStreamMinSize` config; recorded here as part of the full prune.* Originally (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's was dead, and the knob's JSDoc claimed a "read routing" override that did not exist. +2. **`FsTarget.inputPath`** (`packages/fs/fs/src/types.ts`): every backend and every test fake had to fabricate a "diagnostics only" value with zero production readers — the policy plugin and every error message use `targetKey`/`displayPath`. The `listDir` producer exposed the semantic wobble: directory children got the bare entry name, which was nobody's "input". +3. **`FsEditOutcome.replacements` + `.replaceAll`** (`packages/fs/fs/src/types.ts`): `replacements` had zero production readers (the single-match policy itself stays — it is enforced by the `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` throws inside the backend, whose error message keeps the internal count); `replaceAll` was read only by `formatEditOutput` in `packages/fs/tool-fs/src/edit.ts` — as an echo of the `replace_all` argument the tool already holds. Shrunk, `FsEditOutcome` is `{ version, before, after }`, parallel to `FsWriteOutcome`'s genuinely backend-discovered fields. +4. **`FileReadOutcome.limit` + `.version`** (`packages/fs/tool-fs/src/read-render.ts`): populated by the read tool, but `formatReadOutput` renders `offset`/`lines`/`totalLines`/`truncatedByBytes` only, and the `fs/observed` emit uses `info.version` directly rather than an outcome copy. + +## Decision + +Delete the fs-local constant, its re-export, and the `streamMinSize` knob (the remaining `FsIoInternals` knobs are genuinely used by the atomic-write tests); drop `inputPath` from `FsTarget`; shrink `FsEditOutcome` to `{ version, before, after }` and pass `replaceAll` to `formatEditOutput` from the parsed args; drop `limit`/`version` from `FileReadOutcome`. The [filesystem.md](../../../core-data-structures/filesystem.md) pastes, `packages/fs/fs/README.md`, and the test fakes that had to fabricate the removed fields shrink with the types. + +## Alternatives considered + +### Why not keep them? + +A future permission/containment layer might want the pre-resolution path for error text — but it would want the *request*, which every call site still holds. "N occurrences replaced" might become model-facing text — a behavior change to design when wanted, and the backend-internal count survives for its error message. A read footer might display `limit` — everything the footer shows already derives from `lines`/`totalLines`. Meanwhile every current and future backend (remote, native) would have to fabricate wire fields nobody consumes, and every test fake would have to satisfy them. + +## Verification + +The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditRequest`) and the version fields on the other outcome types are untouched; the test fakes shrank with the types. `formatEditOutput`'s emitted text is unchanged for both `replace_all` branches, so no snapshot golden churned. + +## Consequences + +Backends gain no new obligations; they shed four fields nobody consumed. The fs discovery work (glob/grep tools) touches the same `dsh-fs` type files — a textual, not design, overlap that reconciles mechanically. diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md new file mode 100644 index 0000000000..4c76d7f044 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -0,0 +1,31 @@ +# RFC: Remove the `agent/steering` mirror emit + +Status: implemented + +## Problem + +`agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. + +Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC makes. The [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) kept it as a live control signal rather than a boundary; the [stream-chunk removal](2026-07-02-remove-stream-chunk-mirror.md) retained it on the reading that it had no durable twin. The second rationale did not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fired at the exact moment its durable twin landed, carrying nothing the log does not. + +Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observed the mirror. + +## Decision + +`agent/steering` is removed from the agent event taxonomy: the declaration in `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainSteering` (whose then-unused `ctx` parameter went with it), the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (the `packages/core/agent-loop/src/loop.ts` module doc and [architecture.md](../../../architecture.md)); the cordis catalog is regenerated without it. The one regression test pins source preservation on the durable `steering/message` event — the fact it pins lives on the log. + +Three implemented RFCs stated the retention, and each is amended per [implemented/AGENTS.md](../AGENTS.md) to point here as the record of the removal: the [boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md)'s retained-list entry, the [stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)'s scope clause, and the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s transient-emit enumeration. + +## Alternatives considered + +### Why not keep it? + +"It is a control signal, not a boundary" — but the taxonomy's operative distinction is mirrored-vs-live-only, not control-vs-boundary, and this event mirrored. A consumer that wants enqueue-time notification has `agent/queued` (with its steering flag); a consumer that wants drain-time notification is by definition asking for the moment `steering/message` is appended, which `session/event` delivers with the same payload plus durability. The rejected [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) defended the steering *capability* — `steer()`, the durable event, continuation forcing — all of which this removal keeps untouched. + +## Verification + +The `agent/steering` spelling survives only in RFC prose (this RFC, the three amended RFCs above, and the frozen [rejected steering-capability RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md), whose text records the proposal it declined); the catalog is regenerated; the retargeted test pins source preservation on `steering/message`. + +## Consequences + +Zero production listeners existed to migrate, and both live-notification needs keep surviving homes: enqueue-time on `agent/queued` (with its `steering` flag), drain-time on `session/event` as the durable `steering/message` lands. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md new file mode 100644 index 0000000000..9f30dc67f8 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -0,0 +1,25 @@ +# RFC: Share the app bins' boot glue instead of maintaining twin copies + +Status: implemented + +## Problem + +`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes. + +## Decision + +The helpers live once, in [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) (`packages/ui/app-boot`, in the `ui` group because the bins are published artifacts whose runtime dependency must itself be published, not `support/`): `resolveConfigPath` (snapshot-aware, the single path resolver for both bins), `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, and `boot`, each parameterized by the bin's diagnostic prefix and injectable at its side-effect seams (the warn sink, the process slice) so the unit suite covers every branch — including `boot()` driven in-process against the real Loader with relative-specifier configs, both the settled-tree happy path and the fiber-less-entry rejection. The package carries the per-file 100% coverage gate; the loader-failure lore has one home. + +Each `bin.ts` is a thin self-executing composition over the shared helpers plus its app-specific lifecycle (the ACP bin: replay-mode env skipping and the stdin-EOF dispose; the stdio bin: nothing extra). The bins stay coverage-excluded and export nothing; the published-artifact guards are unchanged — the built-bin smokes still run each bin under plain node in a node_modules-shaped temp dir (now symlinking `ui/app-boot` too) and still assert the missing-config non-zero exit, per the "real entry path means the published artifact" defensive pattern. The [extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md)'s bin-ownership facts are amended accordingly. + +## Alternatives considered + +### Why not keep the duplication? + +The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) comparable to the deduplicated line count. But app-vs-app sharing was never weighed by the RFC that created the bins — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift was observed fact; and the coverage-gap argument is independent of the dedup argument: this was the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The recorded fallback (extracting only the pure logic into per-app modules) would have ended the exemption but kept two homes for the lore. + +## Consequences + +- A boot-glue change (a new guard, a resolution fix) lands once and both published bins inherit it; the bins cannot drift apart again. +- `dsh-app-boot` stays dependency-light (cordis + the loader/include pair) — it is boot machinery, not app surface. +- The bins' own files are near-trivial compositions; everything with branches lives under the coverage gate. diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md new file mode 100644 index 0000000000..ea86d1c4eb --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -0,0 +1,30 @@ +# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics + +Status: implemented + +## Problem + +Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: + +1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere was the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all). +2. **`HookOutput.suppressOutput`** (same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` got silent nothing with no warn. +3. **`defaultTimeoutMs` was double-defaulted in both bridge configs with a floating literal** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`), two homes per bridge for one protocol-level constant, so the bridges could silently drift apart on the shared default. *The proposal's original remedy — delete the knob outright — was overtaken by the no-hardcoded-tunables audit, which kept the knob as the explicit bridge-owned config (and added `stderrSummaryMaxChars` beside it); what remained to fix was the literal's home.* +4. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently. + +## Decision + +`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`). + +## Alternatives considered + +### Why not keep them? + +The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. + +## Verification + +`HookDialect` is two-valued (`rg "'native'"` in the hooks packages returns nothing); `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer, while `durationMs` stays on `hook/result` and in the fixtures with the replay scrub intact; the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`, with per-hook `timeoutSec` still overriding; and the truncation rule and decision-string rule are defined once, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. + +## Consequences + +The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md new file mode 100644 index 0000000000..13c3635477 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -0,0 +1,24 @@ +# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback + +Status: implemented + +## Problem + +Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: + +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". + +## Decision + +`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for. + +## Alternatives considered + +### Why not keep them? + +`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call. + +## Consequences + +Nothing beyond the fallback rendering trade described above — degenerate paths whose neutral card is more diagnosable than an inferred first-party one. diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index c3f1d1a46b..67404ab49a 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 @@ -1,12 +1,10 @@ # RFC: Property-based testing for protocol-shaped code -Status: implemented (proposed 2026-06-11, accepted 2026-06-14) - -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +Status: implemented > Merges the original proposal and the decision record for one topic. It found a real BlockAssembler duplicate-`block-end` bug on first run. -## Context +## Problem 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. @@ -25,3 +23,5 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe - **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. - A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect. - Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 7f6cd47b48..d587206569 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 @@ -1,10 +1,8 @@ # RFC: ACP snapshot tests — record-once / replay-deterministic -Status: implemented (accepted 2026-06-19) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - -## Context +## Problem 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. @@ -46,7 +44,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. The rest of the tree is not duplicated: both the normal `examples/acp-agent/cordis.yml` and the replay config load the same `@deepseek-ai/dsh-acp-agent` app entry (which bundles the agent-core spine + JSONL persistence + the ACP bridge), differing only in the LLM backend (`llm-deepseek` vs `llm-replay`) and the bash executor line. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. +The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot boot the normal config as-is — `examples/acp-agent/cordis.snapshot.yml` is an include-overlay of `cordis.yml` that disables the `llm-deepseek` entry by id and inserts `llm-replay` (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)); every other entry IS the live tree, loaded through the include. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. ### Two surfaces: normalize, then compare @@ -72,6 +70,12 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack `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 `<dir>/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). +## Alternatives considered + +- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral golden. +- **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. +- **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. + ## 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, 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 `<scenario>/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. 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 2001147be8..d23483eb88 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 @@ -1,12 +1,10 @@ # RFC: Real-API e2e in CI against the external DeepSeek API -Status: implemented (accepted 2026-06-19) +Status: implemented -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> +## Problem -## 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: [docs/testing.md](../../../testing.md) 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. @@ -22,7 +20,7 @@ ci.yml's value is that it is keyless, forkable, and always-green: any contributo ### Cost is not the constraint; reliability is -The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all six `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the AGENTS.md "lean on with-key e2e tests" policy. +The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all six `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy. ### Triggers: trusted events only @@ -86,6 +84,11 @@ What gets worse is the *surrounding* model, and these are the things to address None of these require changing the workflow to go public; they are operational steps plus the already-added `pull_request_target` guard comment. +## Alternatives considered + +- **A secret-consuming job inside ci.yml** — rejected: it would couple the keyless, forkable, always-green gate to credential availability and a different trigger/concurrency policy; different lifecycles, different files. +- **Omitting the `pull_request` trigger** (the smaller key-exposure surface) — rejected for the pre-merge signal; the Security section carries the accepted exposure analysis. + ## Consequences A second CI workflow and the first repo secret to maintain. The real-API suite now gates merges (pre-merge on trusted PRs, post-merge on the main branch) and runs nightly, so a real break in the agent's interaction with the external API surfaces in CI rather than only in a developer's local run — at the cost of real (but internally free) API calls on every trusted PR and merge. The preflight makes secret misconfiguration self-announcing instead of silently disabling the net. diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index af7cd59d06..07a1cfd731 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,6 +1,6 @@ # RFC: Use `session.jsonl` as the only snapshot session-log artifact -Status: implemented (proposed and accepted 2026-06-20) +Status: implemented ## Problem @@ -8,9 +8,9 @@ Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.gold 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 +## Decision -Remove the `session.golden.jsonl` concept entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`: +The `session.golden.jsonl` concept is removed 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. @@ -18,15 +18,15 @@ Remove the `session.golden.jsonl` concept entirely. Every scenario has at most o Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. -## Acceptance criteria +## Alternatives considered -- `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/testing/2026-06-19-acp-snapshot-tests.md) is updated to describe the reduced fixture set. +**Normalizing both sides against a shared (replay-run) context** — rejected: `normalizeSessionLog` scrubs cwd by exact string match, so the fixture's recorded cwd would survive unscrubbed and every compare would fail. Each side normalizes against its own header-derived context — the implementation note below carries the mechanics. -## What we give up +## Verification + +`session.golden.jsonl` appears nowhere in the snapshot harness, fixtures, orphan guards, or docs; the snapshot test derives the expected session log from `session.jsonl` for every model scenario; authored sidecar scenarios commit their expected produced log as `session.jsonl` with `replay.override.json` as the model-behavior override; and the orphan-fixture guards know which files each scenario kind requires. The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) describes the reduced fixture set. + +## Consequences 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/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index 2ad44e2040..b2c39047b9 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -25,3 +25,5 @@ The fork backend seeds the child with the parent's **balanced completed-turn pre - `subagent-mixed` is the first snapshot scenario to drive two *different* subagent backends in one transcript, exercising the per-session replay keying across a spawn and a fork child simultaneously. - Out-of-process (ACP) subagent replay remains a different shape (each child is its own process with its own replay) and is still tracked as `TODO(acp-subagent-replay)` — these scenarios are in-process only. - Re-recording (`pnpm run test:snapshot:record`) regenerates all four fork/spawn fixtures from the live API; the two new scenarios self-skip without a key like every recorded scenario. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index e72175e544..d114b25176 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -31,6 +31,8 @@ This keys by WHO calls, not by global call order — so it stays correct even if The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site. +## Alternatives considered + The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. ### 3. The harness harvests every log, primary-first diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md new file mode 100644 index 0000000000..47db2cd793 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -0,0 +1,48 @@ +# RFC: Hook snapshot matrix — end-to-end goldens for both bridges + +Status: implemented + +## Problem + +The hook bridges — [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) (7 Claude Code hook points) and [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex) (5 Codex points) — map external hook commands onto the harness interception seams. They carry deep unit and coverage-spec coverage (every decision arm, every payload dialect, driven against a mocked seam) plus one key-gated e2e (`hooks.e2e.ts`, a live `PreToolUse` block). But the full-transcript snapshot tier — the one net that boots the real `acp-agent` subprocess, replays a recorded session keyless, and diffs the normalized ACP stdout + re-persisted log against committed goldens — covered exactly ONE hook: a Claude `UserPromptSubmit` block (`hook-cc-promptsubmit-block`). + +That is the tier a mocked unit test structurally cannot be: it exercises the REAL bridge translating a REAL hook process's outcome into the REAL seam decision, then the REAL loop's reaction, rendered exactly as an editor sees it. A bridge-translation or loop-structure regression that left every unit green would still escape it for every hook point but one — and for the Codex bridge, the ACP example did not even LOAD it, so no Codex hook could fire end-to-end at all. + +## Decision + +Two coupled changes, in one PR: + +### 1. The ACP example ships BOTH hook bridges + +`examples/acp-agent/cordis.yml` and `cordis.snapshot.yml` now load `dsh-hooks-codex` alongside `dsh-hooks-claude`, each pointed at its own config file (`./hooks.json` for Claude, `./codex-hooks.json` for Codex — the two dialects cannot share one file). This is a genuine product-surface change, not test-only wiring: the shipped ACP server (and the `demo:acp` front door) now carries both bridges. + +It is safe because a bridge whose config file is absent is a **silent no-op**: `apply()` catches the read failure, logs through `ctx.logger`, and registers nothing — zero listeners, zero session events. The `acp-agent` app ships no stdout logger, so the warning cannot reach the ACP JSON-RPC channel. A scenario (or a real project) that wants only Claude hooks ships only `hooks.json`; the Codex bridge sees no `codex-hooks.json` and vanishes. This was verified empirically: with both bridges loaded, all pre-existing snapshots (none of which ship a `codex-hooks.json`) are byte-identical. + +Loading both is the minimum that lets the snapshot tier exercise each dialect against the same real app the product ships. Recording (which boots `cordis.yml`) loads both by construction, and replay inherits them the same way: `cordis.snapshot.yml` is an include-overlay of `cordis.yml` that swaps only the llm entry (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)), so a bridge added to the live tree is in the replay tree with no second edit. + +### 2. A snapshot scenario per hook point × its headline outcome, both dialects + +Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-<dialect>-<point>-<outcome>`: + +- **Authored, no model turn** (keyless, no sidecar — the derived replay script is empty; the `rejected` turn carrying `hook/*` events is compared): `hook-cc-promptsubmit-block`, `hook-codex-promptsubmit-block`. +- **Recorded against the real API, hook active during recording** (the model's reaction to the decision is part of the captured transcript, replayed keyless thereafter): `hook-{cc,codex}-promptsubmit-context` (allow + additionalContext fold), `hook-cc-pretool-deny` / `hook-codex-pretool-block` (deny → `isError` tool result), `hook-cc-pretool-ask` (ask → degrades to deny with the approval-required reason), `hook-{cc,codex}-posttool-block` (block with feedback), `hook-{cc,codex}-posttool-context` (accept + additionalContext), `hook-{cc,codex}-stop-continue` (a blocking Stop hook forces one extra step via steering). + +Each hook command emits only FIXED LITERAL strings (no timestamps/pids/`$RANDOM`/cwd echoes); the snapshot normalizer scrubs the one volatile field a `hook/result` carries (`durationMs`). The `Stop` scenarios self-limit with a marker file (`.stop_fired`) so the force-continue does not loop — the `stop_hook_active` loop-guard is still a bridge `TODO`, so an unconditional Stop hook would force-continue every step. + +### Three hook points are deliberately NOT snapshotted + +Discovered while building the matrix, and documented here because the omission is a decision, not an oversight: + +- **`SessionStart` and `SubagentStart`** inject context through a detached, best-effort `void runPoint(...).then(agent.inject())` with NO turn binding. The resulting `context/message` races the work it precedes (the first model request / the child's first turn) and lands at a nondeterministic log position. A recorded golden does not even reproduce on its own replay — a 10× replay stability check failed 10/10 for both. They stay on the bridges' unit coverage, which drives the seam directly without the timing race. (If the injection is ever made turn-bound and deterministic — the direction the `TODO(session-start-gating)` points — these become snapshottable.) +- **`SubagentStop`** is observe-only: its `subagent/end` handler passes no turn (so no `hook/*` log events) and does no injection. It writes NOTHING to the transcript, so a golden would be byte-identical to the no-hook run and could never be proven to fail — a guard that cannot bite. It stays on unit coverage (`bridge.spec.ts` already asserts the observe-only call). + +The matrix therefore covers every hook point that has a DETERMINISTIC, OBSERVABLE transcript footprint, for both dialects. + +## Consequences + +- Every bridge seam mapping with an observable transcript is now guarded at the full-transcript tier, in the real app, for both dialects — including the Codex bridge, which had no end-to-end coverage at all. Recorded goldens capture the model's real reaction to a denied/blocked/force-continued turn, which a hand-authored transcript could only guess at. +- The block scenarios are keyless (no model turn); the rest replay keyless from recorded fixtures. `pnpm run test:snapshot:record` regenerates the recorded fixtures from the live API and self-skips without a key like every recorded scenario. +- The prove-red discipline holds: tampering a hook config's output (e.g. changing a deny reason) turns its scenario red on replay — the hook process runs FOR REAL during replay (only the model is replayed), so the golden guards the actual hook→seam→loop path, not a mock of it. +- The `acp-agent` demo now loads a Codex bridge it will usually no-op (no `codex-hooks.json` in a typical project), which is the intended fail-soft behavior, not a cost. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md new file mode 100644 index 0000000000..358aa64147 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -0,0 +1,25 @@ +# RFC: Single-source the acp-agent replay config + +Status: implemented + +## Problem + +`examples/acp-agent` shipped two hand-maintained configs: `cordis.yml` (the live tree) and a `cordis.snapshot.yml` that mirrored it entry-for-entry with only the llm backend swapped — stripped of comments, the entire difference was the eight-line `llm-deepseek` stanza versus the two-line `llm-replay` stanza. Every app-shape change had to be made twice, and nothing gated the symmetry: if the copies drifted, the snapshot tier would silently exercise a different app than the one that ships — the ["green units, broken product" class of gap](../../../postmortem/0001-acp-default-export-drops-inject.md) the snapshot tier exists to close, reintroduced one level up, with reviewer vigilance as the only defense. + +## Decision + +`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry (matched by id AND asserted by `name`, so a reused id can never disable the wrong plugin) and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with an optional name assertion, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). + +One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included. + +## Alternatives considered + +### Why not the alternatives? + +Keeping the full twin with a symmetry verify-gate was the recorded fallback — it would have removed the silent-drift class but kept a 125-line near-copy whose only content was one entry's difference, growing with every plugin the app gains. A bin-side swap (parse the config, replace the entry, delete the file) would have put YAML surgery inside a published artifact and moved the replay delta out of sight; the overlay keeps the delta declarative, readable, and next to the base config — the teaching value the twin's defenders actually wanted. + +## Consequences + +- A plugin added to `cordis.yml` is in the replay tree with no second edit; the drift class is structurally gone rather than gated. +- The overlay depends on entries carrying stable `id:`s. The `name` assertion on the disable patch guards mis-targeting (a reused id skips the patch instead of disabling the wrong plugin). An id RENAME degrades the patch to a skip whose warning needs a logger the replay app deliberately lacks — the observable result is a futile keyless `llm-deepseek` entry alongside `llm-replay`, with replay output still correct (`llm-replay` owns the stream short-circuit); config rot for review to catch, not wrong snapshots. A top-level insert whose id collides with an existing entry resolves last-wins through the loader's id map — the current config has no collision, and a new patch line is where one would be introduced. +- If a future replay tree needs a second divergence (another backend swapped), it is one more patch line, not a second fork of the file. diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index a48caf11d4..e1792d3ca3 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## 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. @@ -36,7 +34,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim 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. -## Options +## Alternatives considered ### A. Status quo — merge-extensible types + `isJsonValue` at the durable boundary Keep the compile-time pattern. Persistence stays opaque-JSON + serializability guard. Plugins extend via declaration merging; correctness of event *shape* is the producer's responsibility, enforced by TypeScript at compile time and by the `dsh-invariants` plugin's structural checks in dev. @@ -56,10 +54,20 @@ Replace the merge-extensible maps with a runtime registry the producers contribu - **Pros**: real runtime validation at the durable boundary and at plugin seams; one source of truth; enables generic tooling (auto-generated docs, fuzzing, wire-format checks). - **Cons**: the full blast radius above; **Zod is not currently a direct dependency** (only a transitive dep of `@earendil-works/pi-ai`) and the repo's chosen schema lib is **schemastery** — adopting Zod broadly is itself a dependency decision; declaration-merge ergonomics (one-line plugin extension, full inference) are replaced by runtime registration + manual type wiring; the `assertNever` exhaustiveness guarantee weakens (runtime variants aren't statically exhaustive). -## Recommendation +## Proposal Defer. Do **not** change #33. If runtime validation is wanted at the durable boundary in the near term, **Option B** (schemastery on the closed header/metadata shapes) is the proportionate step and stays within the existing convention. **Option C** is a genuine architecture decision that should be evaluated on its own merits — including whether the chosen library is Zod or schemastery — and, if accepted, land as its own change with its own RFC, not as a side effect of persistence serialization. +## Acceptance criteria + +- The decision state is explicit: Option C proceeds only as its own change with its own implementation RFC — never as a side effect of a persistence PR. +- If Option B is taken up, the closed header/metadata shapes (the JSONL `isHeaderLine` guard and kin) validate through schemastery in place of hand-rolled guards, with the merge-extensible maps untouched. + +## Risks + +- The deferral leaves event `data` structurally unvalidated at the durable boundary: a malformed-but-JSON datum is caught late, by a consumer's `switch` — the status-quo cost, accepted deliberately. +- If Option C is ever adopted, the ergonomic loss is real: one-line declaration merging becomes runtime registration plus manual type wiring, and the `assertNever` static-exhaustiveness guarantee weakens. + ## Open questions - If a registry is adopted, is the library **schemastery** (already in the tree, already the config schema lib) or **Zod** (richer ecosystem, currently only transitive)? Adopting two schema libraries is a cost in itself. 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..f4ebe921b5 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 @@ -22,6 +22,10 @@ The runtime should own: `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. +## Current seam consumption + +A consumer census of the surface the runtime would carve up. Production has two seam consumers: `packages/bash/tool-bash/src/index.ts` consumes `resolve`, `run`, `start`, `ownerOf`, `readOutput`, `kill`, and `onTaskDone`; and the hook bridges — via `dsh-hook-protocol`'s `runHook` (`packages/hooks/hook-protocol/src/runner.ts`) — consume `resolve` + `run` only, a foreground-only trusted-plugin caller that sets the seam's `stdin`/`env` fields, so the background machinery stays single-consumer (which sharpens the extraction premise). `get()`/`list()` have test-harness consumers only — they were removed once and reverted on the merits (the implementation note in [prune dead methods from the persistence seam](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records the test-migration cost dwarfing the surface removed). The per-task `BashTask.done` promise has no consumer through the public seam either (`dsh-tool-bash` completes via `onTaskDone`), but it is production-load-bearing INSIDE the implementation: `dsh-bash-local`'s disposal awaits it to reach quiescence. The seam therefore exposes two public completion representations — the per-task promise and the global `onTaskDone` listener registry — and the shipped consumers use only the latter: the runtime should pick exactly one public completion surface and record which. Two shape facts for the split to dissolve or preserve deliberately: `BashExecSpec.timeoutMs` is required but ignored by `start()` (documented in the seam JSDoc itself), and `stdin`/`env` ride the shared spec for the foreground trusted-plugin path — the carve-up must keep a plain in-process foreground `resolve`+`run` path carrying them, so hook execution is never forced through the long-running runtime. Adjacent blast radius: the credential scrub is duplicated between the two production spawn sites (`packages/bash/bash-local/src/run.ts` and `packages/subagent/subagent-acp/src/run.ts`); if the runtime absorbs spawn-env policy, collapsing that duplication is its work too. + ## Acceptance criteria - The bash-specific packages no longer define the generic task registry, owner-token authorization, polling, cancellation, or completion-notification machinery. @@ -30,6 +34,8 @@ The runtime should own: - 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 +## Risks 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 675e295c2c..c321c56d53 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 @@ -2,12 +2,11 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> > **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<Agent, sessionId>` 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 -The coding agent is reachable only through the readline `stdio-chat` plugin: it reads lines from stdin, calls `agent.send()`, and prints `agent/stream-chunk` to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions. +The coding agent is reachable only through the readline `stdio-chat` plugin: it reads lines from stdin, calls `agent.send()`, and prints the assistant token stream (`session/event` `assistant/chunk`) to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions. 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. @@ -15,7 +14,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch ## 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/architecture/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/pre-execute`/`tools/post-execute` waterfalls. 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. @@ -27,9 +26,9 @@ The mapping between ACP and existing harness seams — each row names the seam a | `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/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 | -| `session/update: agent_thought_chunk` | `agent/stream-chunk` `reasoning-delta` | | +| resolve `session/prompt` → `{stopReason}` | the `turn/end` `session/event` (its `reason`) | 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` | `session/event` `assistant/chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text | +| `session/update: agent_thought_chunk` | `session/event` `assistant/chunk` `reasoning-delta` | | | `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}` | @@ -46,7 +45,7 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom 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/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. +4. Prompt-turn streaming plus load: translate `session/event` (the `assistant/chunk` token stream plus boundaries and tool activity) 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 the `session/event` listener before `send()`; capture the prompt's owning turn from its `turn/start` record, then resolve on that turn's `turn/end` (with `agent/status` idle/disposed as a fallback); 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<Agent, sessionId>` 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. 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. @@ -59,6 +58,17 @@ Deferred (each names its owning future work): - 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`. +## Alternatives considered + +- **A process-wide stdout hijack inside `dsh-acp`** (defensively monkey-patching `console.log` / `process.stdout.write`) — rejected: it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. The stdout guarantee is config-only. +- **Injecting `agentLoop` directly instead of the abstract create/resume factory** — the recorded fallback, taken only if the factory seam is judged not worth it, with the architecture-rule exception recorded in `docs/architecture.md`. + +## Acceptance criteria + +- The `acp-agent` example speaks ACP over stdio end-to-end: `initialize`, `session/new` with a validated absolute `cwd` honored as the session workspace, streamed `session/update` frames per prompt turn, `session/load` re-deriving identical history, and `session/prompt` resolving with the correct wire `stopReason`. +- stdout carries only framed JSON-RPC (asserted by test); the permission gate settles every `session/request_permission` exactly once — on outcome, cancel, or connection close. +- The plan's test set runs green: the property-based protocol invariants, the codec unit tests over an in-memory duplex pair, the HMR-safety test, the failure-path matrix, and the self-skipping real-API e2e that verifies the world. + ## 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. 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 ed51a2ded8..a65f6b67eb 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 @@ -2,7 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> > **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<SessionId, AcpSession>` 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). @@ -30,6 +29,16 @@ The harness core already supports many agents (`AgentRegistry.list()` and `Agent 3. Lift the `session/new` guard; keep `session/load` ([from ACP support](2026-06-14-acp-agent-client-protocol.md)) working per session. 4. Tests for cross-session isolation: two sessions streaming and permission-prompting concurrently never interleave; a cancel/abort in one session leaves the other's stream and pending permission untouched; per-session in-flight-prompt enforcement holds independently; disposing one session leaves the others running. +## Alternatives considered + +**A per-session `ctx.extend()` scope** — rejected: in Cordis, `ctx.extend()` only creates a child context/prototype, and `ctx.on()` registered on it is still owned by the current plugin fiber, so disposing it would not remove that session's listeners. A genuine child fiber (or a per-session collection of disposers) is required. + +## Acceptance criteria + +- N concurrent sessions stream and permission-prompt without interleaving their `session/update` notifications; a cancel in one session leaves every other session's stream, queued prompts, and pending permissions untouched. +- Disposing one session removes exactly its own listeners; connection teardown reaches quiescence across all sessions. +- One session's agent cannot read or kill another session's background bash task. + ## Risks Listener fan-out cost: each session adds listeners; ensure disposal of one session removes exactly its own and the connection teardown ([from ACP support](2026-06-14-acp-agent-client-protocol.md)) still reaches quiescence across all sessions. diff --git a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md index eb41750d1d..39c907a1c2 100644 --- a/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md +++ b/docs/rfc/proposed/feature/2026-06-15-optional-code-mode.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## 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. @@ -55,7 +53,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi **3c. The single tool — `run_code`.** Registered normally in `ctx.tools` with one parameter `{ code: string (required) }`. Because it is an ordinary tool, the unchanged loop dispatches it through the normal path — this is the crux of "zero loop changes." Its `execute(args, exec)`: -1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: <deterministic sub-id>, name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/execute` waterfall, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones. +1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: <deterministic sub-id>, name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/pre-execute`/`tools/post-execute` waterfalls, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones. 2. Calls `ctx.codeRuntime.run({ code: args.code, sdk: bindings, signal: exec.signal })`. 3. Surfaces the outcome. A *successful* run returns `[{ type: 'text', text: <console logs + return value> }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise<ContentBlock[]>` and `ToolRegistry.execute()` hardcodes `isError: false` on any successful return — `isError: true` arises only from the registry's catch path. So on an error result the tool **throws a `CodeRunError extends HarnessError`** (`HarnessError` is exported from `dsh-llm`; the registry catch turns any throw into `isError: true` with the message as text, and a `HarnessError` additionally carries structured `{ name, code }`). An alternative — registering `run_code` handling as a `tools/execute` listener that returns a full `ToolExecutionResult` and can set `isError` directly — is noted; the throw is simpler and preferred. @@ -73,7 +71,7 @@ These are illustrations of the seam's reach, **not commitments** — the MVP shi **Optionality / toggle.** Loading the `code-mode` plugin enables Code Mode for that context; not loading it leaves today's native tool-calling untouched. The two are mutually exclusive within one ctx, because Code Mode rewrites the wire tool list down to `[run_code]`. Per-agent selection via ctx forks, and the visibility tiers above, are future work; the MVP toggle is plugin presence. -## Alternatives +## Alternatives considered **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. @@ -90,6 +88,14 @@ It is insufficient for the **composition / round-trip** half, which is the decis 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). +## Acceptance criteria + +- The three packages exist and pass their suites: `dsh-code-runtime` (the abstract seam), `dsh-code-runtime-vm` (the reference stub whose constructor throws without `{ unsafe: true }`), and `dsh-code-mode` (SDK codegen, the lazy prompt section, the `agent/request` collapse, the `run_code` tool). +- The wire tool list is exactly `[run_code]` under the plugin (spy-adapter test); the generated SDK covers every registered tool, with non-identifier names reachable via quoted access. +- A program calling two tools returns only its curated output; `code/dispatch` events land in the session log and never enter derived history. +- `Promise.all` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (the per-run serialization queue holds); an abort stops further dispatches. +- With `allowUnsafeRuntime` unset over an unsafe runtime, `run_code` is not registered and the wire tool list is unchanged from native. + ## Risks node:vm is not a sandbox. This is the single biggest caveat. Withholding `require`/`process` is not a boundary; the MVP runs at harness trust only; the hardened substrate is a hard prerequisite before any untrusted use and is the explicit subject of a follow-up RFC. The guard is enforceable, not just documented: the runtime exposes `safe: boolean`, the VM stub throws unless constructed with `{ unsafe: true }`, and `code-mode` refuses to register `run_code` over an unsafe runtime unless separately acknowledged (`allowUnsafeRuntime`) — production misuse requires two deliberate, greppable flags, and the refusal path is tested. diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md new file mode 100644 index 0000000000..b3e6ab3cd7 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -0,0 +1,50 @@ +# RFC: Pre-tool input rewrite — a consistent design + +Status: proposed + +## Problem + +The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) added `tools/pre-execute` returning a `PreToolDecision` (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's `arguments` before it runs). Claude Code's `PreToolUse` hook offers an `updatedInput`, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision. + +## The problem: three readers of pre-execution arguments + +In the loop, a tool call's arguments are committed to the log and read by live consumers BEFORE the tool executes: + +1. **`assistant/message`** is appended before tool dispatch — it is the model-history source `deriveMessages()` replays, so it carries the tool-call arguments the model itself emitted. +2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`. +3. **Live presentation reads `tool/call.arguments`**: the ACP bridge remembers them and passes them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them. + +So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate `exec.arguments` in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this — yet not unused: a tool-bash integration test rewrites a scripted call's arguments through it (`packages/bash/tool-bash/tests/integration.spec.ts`), so this design must either sanction that path with the consistency unit below or seal it — `readonly` arguments at the seam, with the test shim moved onto a behavior-level helper.) + +## Proposal + +A sketch, to validate against the code when built. Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution: + +- The `tool/call` audit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping). +- The `assistant/message` in derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect. +- Presentation (`presentCall`/`presentResult`) reads the rewritten arguments, so the UI shows what actually ran. + +The shape would extend `PreToolDecision` with an allow-variant `arguments` (or a dedicated `{kind:'rewrite', arguments}`), and the loop would thread the rewrite through the three readers above rather than only into `ctx.tools.execute()`. + +## Alternatives considered + +### Why not now + +The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an `updatedInput` logs it and surfaces a faithful-but-degraded warning (like `ask`→deny) until this lands. This RFC is the home for the consistent design; `TODO(pre-tool-input-rewrite)` in the loop's pre-execute call site anchors it. + +## Acceptance criteria + +- A `pre-execute` rewrite is reflected in all three readers atomically before execution: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments. +- The unadvertised `exec.arguments` mutation path is either sanctioned by this consistency unit or sealed (`readonly` arguments at the seam, the test shim moved onto a behavior-level helper). +- The CC/Codex bridges honor `updatedInput` instead of logging the faithful-but-degraded warning. + +## Risks + +- Rewriting the `assistant/message` tool-call block changes what the model "sees it said"; whether any provider rejects that on replay is the open question that must be settled empirically before the decision shape freezes. +- Until this lands, the unadvertised mutation path keeps its latent UI-desync inconsistency. + +## Open questions + +- Does rewriting the `assistant/message` tool-call block corrupt any provider's expectation on replay, or is a separate correction safer? +- Should the original arguments be preserved on the `tool/call` event (audit) and, if so, under what field? +- How does this interact with a future permission `ask` flow (a user approving a rewritten call)? diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md index c1099a6cce..a6f3bfb14c 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - > 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 @@ -14,6 +12,19 @@ Public API changes are invisible — nothing makes "this commit changed the publ api-extractor (or `tsc --emitDeclarationOnly` + a normalized public-surface dump) producing a checked-in `etc/<pkg>.api.md` per package; CI fails if regeneration differs. Every public-API change becomes a diff line a reviewer (or review agent) must see. -## Status / why deferred +## Alternatives considered + +**`tsc --emitDeclarationOnly` plus a normalized public-surface dump** — the lighter mechanism if api-extractor proves too heavy; either satisfies the checked-in, diffable report shape the proposal needs. + +## Acceptance criteria + +- Every package has a checked-in `etc/<pkg>.api.md`; CI fails when regeneration differs from the committed report. +- A public-API change (a new export, a widened field, a shifted signature) is visible as a report diff line in review. + +## Risks + +The dependency is heavy and finicky — the reason this was deferred — and the report format churns with compiler upgrades, adding a maintenance surface that buys little while the packages stay unpublished. + +## Why deferred Deferred when doc-sync landed: low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. Revisit if the packages are ever published externally — at that point a stable, diffable public surface earns its keep. diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md index 6eaf8b03a7..e0d16b455d 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## Problem 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)). @@ -24,6 +22,13 @@ Two architectural guarantees currently live only in prose: (1) nothing depends o dependency-cruiser config + CI step first (an hour of work, permanent guarantee); the conformance kit lands with its first consumer test against MockAdapter, and is a prerequisite for the V4 adapter phase. +## Acceptance criteria + +- dependency-cruiser runs in CI with the rule families above; a violating import fails the build. +- The conformance kit runs against the mock adapter and both shipping adapters, and a new adapter package inherits the suite by invoking it with its factory. + ## Risks Dep-cruiser rule maintenance as packages are added — keep rules pattern-based (`dsh-*`) rather than enumerated. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md index e35e79f0a5..e787133026 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## Problem 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. @@ -19,6 +17,17 @@ The vendor manifest ([the vendoring decision](../../implemented/process/2026-06- 3 is trivial — do first. 1 requires network access from CI to the upstream repos (private — needs a token) and converting the two existing logged modifications into patch files. 2 and 4 are config. +## Alternatives considered + +- **`pnpm audit` instead of osv-scanner** — either satisfies the advisory-scanning shape; the choice is deferred to implementation. +- **A scheduled agent task instead of Renovate** — equivalent for proposing small update PRs that ride the full gate suite; vendored packages stay excluded either way (their updates follow the manifest sync procedure). + +## Acceptance criteria + +- The license inventory script runs in CI and fails on a missing LICENSE or a `license` field that contradicts the inventory in `vendor/README.md`. +- The nightly drift job reconstructs `vendor/` from the manifest SHAs plus checked-in patch files and fails on any unexplained diff. +- Advisory scanning runs on the lockfile on schedule and on lockfile-touching PRs. + ## Risks Upstream repos are private mirrors; CI credentials and availability are the main friction for the drift check. If blocked, run it as a local scheduled agent task instead of CI. 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 784a36c593..3587393efe 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` lists all 18 packages as explicit project `references`. 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` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). The ACP snapshot suite's scenario table (`examples/acp-agent/tests/acp.snapshot.ts`) hand-maintains a `childSessions` count per scenario that duplicates the number of `session.<n>.jsonl` fixture siblings on disk. These lists are small today, but every new package or scenario class 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/<group>/<pkg>` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). @@ -16,13 +16,19 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. +Two of the cataloged items need no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright, and `childSessions` can be discovered from each scenario's fixture directory, leaving the scenario table to declare only policy (`recorded`, `hasModelTurn`, `comparesLog`) — and even those track fixture-derivable facts today (`comparesLog` ⟺ the committed log has entries beyond its header line; `recorded` ⟺ `hasModelTurn` with no `replay.override.json` sibling), so each new scenario class keeps adding knobs the fixture directory already answers. + ## Acceptance criteria - `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. +- `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. +- Snapshot scenarios declare policy, not facts discoverable from their fixture directories. -## What we give up +## Risks 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md deleted file mode 100644 index 4b1cd75a56..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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/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`, `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. - -## 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. -- `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. - -## 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 - -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/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index cc0db091dc..19dee0ed56 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -9,12 +9,13 @@ The agent factory carries TWO ids for what is, in every live consumer, one thing - `agentId` — the `AgentRegistry` handle (the actor identity; the registry rejects a duplicate). - `sessionId` — the event-sourced session / persisted-log identity (`session.header.id`). -`CreateAgentOptions` takes both separately; `ResumeAgentOptions` takes an `agentId` plus a `resumeSessionId`. They diverge in exactly two places: +`CreateAgentOptions` takes both separately; `ResumeAgentOptions` takes an `agentId` plus a `resumeSessionId`. They diverge in exactly three places: - **Config-driven create** (`AgentLoop.create`): a stable `agentId` (e.g. `"echo"`) with a fresh per-run `sessionId` (`${id}-session-<uuid>`). - **Resume**: a caller-supplied `agentId` (e.g. `"main"`) on a persisted `resumeSessionId`. +- **In-process subagent children**: the backend mints the child's `agentId` and `sessionId` as two independent UUIDs (`packages/subagent/subagent-inprocess/src/index.ts`) that nothing distinguishes — `parentSession` records lineage independently. -Everywhere a live consumer actually looks an agent up — the **ACP bridge, the only production path** — the two are already unified: `agentId === sessionId === <uuid>`. +Where a live consumer looks an agent up, no lookup needs an id translation: the ACP bridge — the primary production path — already unifies the two (`agentId === sessionId === <uuid>`; both factory call sites brand `AgentId(sessionId)` directly, and its reverse lookup keys on the `Agent` object itself), and the CC hooks bridge resolves subagent children directly by the `agentId` its lifecycle event carries. The one production population whose two ids actually DIVERGE is the in-process subagent children — the same cosmetic separation as the config path, and the same one-field simplification under unification. One consumer already pays the two-id tax: ui-stdio keeps a `labelBySession` map (seeded from the registry, maintained by `agent/created`/`agent/disposed` listeners) solely to translate `session.header.id` back to an agent id for its turn labels — machinery that deletes outright when the ids unify. And the CC hooks bridge stamps `session_id: agent.session.header.id` into every hook payload, so under unification a subagent hook's `session_id` and `agent_id` become the same string — one less identity for a hook author to reconcile. 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. @@ -27,7 +28,9 @@ Make an agent BE its session: one id. An agent's registry handle IS its `session - 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()`? +## Alternatives considered + +### 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. @@ -40,7 +43,7 @@ That was the review's first suggestion. It would couple the generic registry to ## 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. +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); the bash owner-token precondition it closes is documented in [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). The genuine risks of collapsing the two ids into one (the case AGAINST this proposal — to be weighed honestly before implementing): diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md new file mode 100644 index 0000000000..a6f932b1ed --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -0,0 +1,32 @@ +# RFC: Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId` + +Status: proposed + +## Problem + +Three pieces of public spine surface share one defect class: their only possible role is to be ignored, or their trigger is unreachable. + +1. **`SurfaceManager.invalidate()`** (`packages/core/session/src/surface.ts`). Its documented trigger — "the log has been replaced wholesale (e.g. after Session seed)" — is structurally unreachable: seeding happens inside the `Session` constructor, `_surface` is created lazily on first access, and the log reference is never reassigned afterward, so no constructed `SurfaceManager` ever observes a wholesale replacement. Sole caller: its own unit test. A rollback primitive protecting a scenario the implementation cannot produce. +2. **The `runLoop`, `Inbox`, and `InboxMessage` exports** (`packages/core/agent-loop/src/index.ts`). `runLoop` has no importer outside the package — the only callers are the package's own internals (the agent constructs its loop with it), so the public re-export has zero consumers; `Inbox`/`InboxMessage` likewise reach outside code only through the package's own inbox spec (switchable to the source module). The exports contradict the package's own docs — the inbox module doc says the public surface is `Agent.send()`/`Agent.steer()` — and the [architecture dependency rule](../../../architecture.md): nothing programs against `dsh-agent-loop`; a replacement loop is a different bundle built on `dsh-agent`, not a consumer of this package's internals. `ReactLoopAgent` stays exported (cross-package tests construct it by package name). +3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the *input* `ToolExecution.callId` stays). Zero readers — and no listener can even construct a result: `tools/pre-execute`/`tools/post-execute` listeners return Decisions, the registry builds every result itself and always sets `callId` to the input `exec.callId`, and the post-execute dispatch snapshots the outcome before the waterfall precisely so a listener mutating the shared result reference cannot corrupt the id. The loop independently ignores `result.callId` in favor of its own `call.id`, and two regression tests exist solely to prove the field cannot matter (the loop's ignores-result-callId test and the registry's mutation guard). A field that is by construction a copy of its input, defended by snapshot machinery, and pinned by tests proving it is ignored is pure liability surface; the ACP bridge correlates via the session event's `data.callId`, never via the execution result. + +## Proposal + +Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (the deny result, the dispatch result, `toolErrorResult`, and the post-execute snapshot's `callId` leg), the loop's ignore-comment, the proves-ignored regression test, and the mutation guard's `callId` assertions — the hazard they all pin disappears with the field, while the result's `additionalContext` ferry (a consumed post-execute channel) stays untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md). + +Sequencing: the in-flight surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal lands after or alongside it mechanically. The execute pipeline is `tools/pre-execute` → dispatch → `tools/post-execute`, and post-execute listeners receive the execution object alongside the result — nothing needs the result's own id. + +## Alternatives considered + +### Why not keep them? + +A future consumer that swaps a session's log in place would want a reset primitive — it re-adds `invalidate` with itself. A replacement-loop author might want to reuse the inbox or the driver — the architecture already answers that a replacement loop is a different bundle. An isolated result-logging listener might want self-contained correlation on the result — the execution object is in scope at every listener, and a field that exists only to be ignored is worse than absent: it invites exactly the orphaned-pairing bug the loop comment warns about. + +## Acceptance criteria + +- `invalidate()` and the result `callId` appear only in this RFC; `runLoop`/`Inbox`/`InboxMessage` remain package-internal only — no re-export from the package index and no outside-package importer; the agent-loop README lists only the consumed public surface; the inbox spec imports the source module. +- The pre-/post-execute pipeline contract tests pass with the shrunk result type; the mutation-guard and proves-ignored tests shed their `callId` legs with the hazard they pin. + +## Risks + +All three are compile-visible removals with no runtime behavior change on any shipped path. diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md index 6337d6b82f..2a30969ba4 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## Problem Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously. @@ -20,6 +18,14 @@ Three measures: Land 1 and 2 together (they touch the same helpers); add the nightly job after the suite is sleep-free so repeats are fast. +## Acceptance criteria + +- No `setTimeout` remains in `packages/*/tests` outside the allowlisted helper module, enforced by the lint rule. +- The shared harness replays every test's session log into a fresh `Session` and asserts `deriveMessages()` equality automatically, across the whole suite. +- The nightly job runs the agent-loop and inbox suites with `--repeat` and `--shuffle`; a flake it finds is triaged as a bug, never retried away. + ## Risks Fake timers interact subtly with Promise scheduling in the loop — prefer event-driven waits; reserve fake timers for timer-service behavior itself. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md index 209b0cdbd0..8c68ccf09f 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md @@ -2,8 +2,6 @@ Status: proposed -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## Problem 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. @@ -23,6 +21,14 @@ Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`: 2. Expand to all packages; record baseline scores in the config. 3. Wire the nightly job; add the incremental PR job once runtime is acceptable. +## Acceptance criteria + +- A Stryker config runs over `packages/*/src` with the vitest runner; a nightly job records the mutation score, and a ratcheting threshold fails the run when the score drops below the recorded baseline. +- PR-scoped incremental runs gate merges once runtime is acceptable — or are explicitly kept nightly-only, with that outcome recorded here. +- Equivalent mutants carry annotated exclusions with reasons, mirroring the `/* v8 ignore */` policy. + ## Risks Runtime: mutation testing is expensive; per-file 100% coverage helps (every mutant is at least reached). If PR-scoped runs stay too slow, keep them nightly-only and rely on the score ratchet. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 0e791ef689..6c404a05a9 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -2,8 +2,6 @@ Status: rejected — the pervasive `DeepReadonly<T>` 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). -<!-- XXX: legacy ADR/RFC body format, not yet normalized to a unified RFC template. --> - ## Problem The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish. @@ -26,3 +24,5 @@ Introduce `DeepReadonly`, flip the session read paths, fix resulting compile err ## Risks `DeepReadonly` types can produce noisy errors at waterfall boundaries where mutation IS the API — keep the mutable/readonly boundary exactly at "logged vs in-flight" and document it in the session README. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 7856c04b81..9746d4ac07 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 @@ -25,3 +25,5 @@ The shared base should contain only provider-neutral services and tools: `llm`, ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 56f20c04ef..088fa8d25d 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 @@ -30,3 +30,5 @@ 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/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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 f6cc3a3236..18cd6e981d 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 @@ -23,3 +23,5 @@ For now, ACP starts fresh sessions only. `initialize` advertises `loadSession: f ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 4b7ca91a0c..2f1408dc4c 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -25,3 +25,5 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md index a6a47c66a0..0a13d90f1b 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md @@ -20,8 +20,10 @@ 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 [docs/defensive-patterns.md](../../../defensive-patterns.md) stops 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md index 833fae5bc6..94313fd0ad 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md @@ -26,3 +26,5 @@ 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, 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md index f32b0f3461..fc06cc76c9 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md @@ -25,3 +25,5 @@ If lineage returns, decide then whether it belongs in the immutable header, a se ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 229e2810fc..a59c992b0d 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 @@ -25,3 +25,5 @@ The implementing PR should update the [capability seams](../../implemented/archi ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md index 5b80920a6a..6125feaa8b 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -12,6 +12,8 @@ The real first-party use is bash presentation for ACP. That is too little eviden 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. +## Alternatives considered + 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 diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md index 97c7d5dc61..fd1a4687b3 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md @@ -31,3 +31,5 @@ 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md index b2a2c3b84f..e68cd24346 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md @@ -25,3 +25,5 @@ Remove the multi-session maps and demux where a single `SessionRecord | undefine ## 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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> 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 90e2b3f7a4..1ed26c66f8 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 @@ -30,3 +30,5 @@ 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/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. + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md new file mode 100644 index 0000000000..1af873731c --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -0,0 +1,37 @@ +# RFC: Prune the unimplemented subagent seam vocabulary + +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. + +## Problem + +The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: + +- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): every real provider declares `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) builds `{ prompt, parent, signal?, agentOptions? }` and structurally cannot set either; `structured` is produced only by the test mock (`packages/support/subagent-mock`) for its own spec. The service's capability check carries two assert rows whose only exercisers are the rejection tests. +- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. + +The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. + +## Proposal + +Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the mock's structured branch and its `capabilities`/`structured` config knobs, and the tests that exist to pin the removed surface (the two rejection rows, the spawn absence test, the mock structured specs). Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, and the README rows in `packages/subagent/subagent`, `packages/subagent/subagent-spawn`, `packages/subagent/subagent-fork`, and `packages/support/subagent-mock`. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). + +**Keep** `depthLimit`/`maxDepth` and the capability-check mechanism itself — with eyes open about its current reach. The in-process backend genuinely enforces the cap (`SubagentDepthError` in `packages/subagent/subagent-inprocess/src/index.ts`), but no production request sets `maxDepth` (`tool-subagent` exposes no knob for it), so on the shipped tool path the guard is dormant and recursion is uncapped. The alternative — remove the depth machinery too, on the argument that a dormant guard reads like a safety property while providing none — was considered and rejected: recursion is the seam RFC's named risk, the enforcement is real working code rather than vocabulary awaiting an implementation, and the honest completion is wiring a default cap through `tool-subagent` (a few-line feature) rather than deleting the only existing guard. One live capability row also keeps the two-tier design demonstrated rather than merely remembered. + +Adjacent surface examined and deliberately left alone: `SubagentService.getProvider()`/`list()` have test-harness consumers only, but the [prune-dead-seam-methods implementation note](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records precisely this shape being removed from the bash executor and reverted — a test harness IS a consumer for a one-line accessor over an already-tracked map. `SubagentRunEndInfo.lastAssistantMessage` is a recorded keep (the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)'s review dropped `agentType` and kept it deliberately, as the only final-message channel for out-of-process children); its currently-unwired bridge forwarding is a gap to close or a consumer to document, not surface for this RFC to cut. + +This is the seam-vocabulary echo of [prune dead methods from the persistence seam](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md): members every implementation must declare for nobody — weaker even, since here zero implementations exist. + +## Alternatives considered + +### Why not keep it? + +The two-kinds-of-capability design is the seam RFC's headline, and re-adding `outputSchema` later touches several files. But the design survives with `depthLimit` as its live example and the RFCs as its record, and the seam RFC itself concedes the shipped `toolFilter` shape is wrong (real enforcement needs a `tools/pre-execute` deny in the child's context, not schema filtering) — that deny primitive exists on the interception seams, so re-adding against a real implementing provider will pin a better contract than the current speculative one. + +## Acceptance criteria + +- The removed spellings appear only in this RFC and the amended seam RFCs; `SubagentCapabilities` is `{ depthLimit: boolean }`; the `dsh-tools` dependency edge is gone (`hygiene` green). +- Depth-enforcement tests are unchanged and green. + +## Risks + +The subagent lifecycle events carry `lastAssistantMessage` on the end payload — that enrichment lives in the service module, not the seam vocabulary this RFC shrinks, and the observe-enrich RFC records dropping an `agentType` sibling for lacking a consumer: the judgment this RFC extends. The CC hooks bridge, the first outside consumer of those lifecycle events, reads only the event payloads and touches none of the surface removed here; the observe-enrich RFC's deferred control-flow redesign names implementing `resume` as its own future work — exactly the re-add trigger this RFC's pattern anticipates. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..8dbbbc40c2 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,33 @@ +# Testing policy + +How this repo tests, tier by tier, and the rules that keep a green suite meaning something. Commands live in the root [AGENTS.md](../AGENTS.md) § Commands; the RFCs linked per tier carry the design rationale. + +## Tiers + +- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Excessive tests are welcome — err toward covering edge cases, error paths, event ordering, and concurrency races; review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). +- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. +- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). +- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Re-record with `pnpm run test:snapshot:record`; reviewing the golden diff is part of the review. + +## The with-key policy: inference is cheap here + +We are DeepSeek — do not ration real-API tests. A no-key test proves the plumbing; only a with-key run proves the agent works against a real model. Write many: real prompts that write files, multi-turn conversations, tool use, cancellation mid-stream. Cheapest and highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships a keyless smoke and — unless keyless-by-nature — a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). + +## Prefer the real implementation over a mock + +Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. Example: bridge tool-call tests run the scripted mock MODEL but the real tool + real executor (`makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`). + +## Verify the world, not the self-report + +An e2e assertion re-runs the command or re-reads the file externally; a keyword probe on the agent's own output lets a cheating agent pass. Assert untouched files are byte-identical. e2e tests own their resources: create the harness in the test, dispose in `afterEach` (even on failure/retry/timeout); shared fixtures live in a plain `tests/harness.ts`, never another `*.e2e.ts` (importing a spec re-registers its `describe` and duplicates real API calls). + +## Test the real entry path + +- A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). +- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. +- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). Keep the built-bin smokes green (`packages/ui/*/tests/built-bin.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). + +## When a snapshot test is required + +Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario under `examples/acp-agent/tests/snapshots/` (or states in the PR why none applies). New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 97400f8223..17b4295eda 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -3,12 +3,24 @@ # Tool Schema Catalog -Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](../cordis-catalog/events.md) & [services](../cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +## Tool Package Map + +This table connects model-visible tool names to the plugin package and service seams behind them. Exact JSON Schemas follow in the package sections below. + +| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | +| --- | --- | --- | --- | --- | --- | +| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | +| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | + ## `@deepseek-ai/dsh-tool-bash` ### `bash` @@ -91,6 +103,102 @@ Read new output from a background bash task started with `bash` + `run_in_backgr Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) +The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. + +## `@deepseek-ai/dsh-tool-fs` + +### `edit` + +Edit an existing UTF-8 text file by replacing literal text. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +### `read` + +Read a UTF-8 text file and return line-numbered content. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +### `write` + +Create or fully replace a UTF-8 text file. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts) + +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. + ## `@deepseek-ai/dsh-tool-subagent` ### `subagent` @@ -165,3 +273,55 @@ Record and update a structured task list for the current work. Send the ENTIRE l ``` Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts) + +todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. + +## `@deepseek-ai/dsh-tool-web` + +### `web_fetch` + +Fetch the content of a specific HTTP(S) URL and return it decoded to text. + +```json +{ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The HTTP(S) URL to fetch." + }, + "timeout_ms": { + "type": "number", + "description": "Optional fetch timeout in milliseconds (capped by the provider)." + } + }, + "required": [ + "url" + ] +} +``` + +Source: [`packages/web/tool-web/src/index.ts`](../../packages/web/tool-web/src/index.ts) + +### `web_search` + +Search the web for current information. Returns an optional summary answer and a list of source URLs. + +```json +{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query." + } + }, + "required": [ + "query" + ] +} +``` + +Source: [`packages/web/tool-web/src/index.ts`](../../packages/web/tool-web/src/index.ts) + +web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md new file mode 100644 index 0000000000..db50a3beec --- /dev/null +++ b/docs/tool-execution-pipeline.md @@ -0,0 +1,39 @@ +<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand. + Run `pnpm run gen-doc-graphs` to regenerate. --> + +# Tool Execution Pipeline + +This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute` and `tools/post-execute` waterfalls. + +```mermaid +flowchart TD + model["Assistant message contains tool-call block"] + toolCall["Session event: <code>tool/call</code><br/>logged before execution"] + presentCall["UI pending card<br/>presentCall(args)"] + pre["<code>tools/pre-execute</code> waterfall<br/>hooks, permission, sandbox"] + denied["deny or ask<br/>tool body skipped"] + toolBody["Registered tool execute() body"] + fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"] + owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>"] + post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"] + context["Buffered additionalContext<br/>context/message after all tool results"] + toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"] + presentResult["UI completed card<br/>presentResult(args, result)"] + model --> toolCall + toolCall --> presentCall + toolCall --> pre + pre -->|allow| toolBody + pre -->|deny or ask| denied + denied --> post + toolBody --> fsGate + fsGate --> toolBody + toolBody --> owned + toolBody --> post + post --> context + post --> toolResult + toolResult --> presentResult +``` + +Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. + +Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index c5a797f5df..6c1cc717df 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -9,7 +9,7 @@ Because examples are not under the `packages/*/src` coverage gate, an example th Each example must have **both** kinds of end-to-end smoke, because they catch different failures: - **Keyless smoke** — boot the example through its real `cordis.yml` via the Loader (no API key), drive it, and assert the rendered output and a clean exit. This is the guard a hand-mounted unit test structurally cannot be: it exercises the REAL load path (`unwrapExports`, `inject`, the whole plugin tree), so a broken plugin export shape — e.g. a stray `export default` that collapses a namespace plugin and drops `inject` — fails here even when unit tests stay green (see [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md)). It runs in the default e2e gate (CI has no secrets). -- **With-key smoke** — send a real prompt against the live model and verify the WORLD (a file on disk, a non-empty assistant turn), not the agent's self-report. This proves the actual product works, which a mock/keyless run structurally cannot. Key-gated: it self-skips without `DEEPSEEK_API_KEY` (see [the with-key policy](../AGENTS.md#secrets--env) — inference is cheap here, so write many). +- **With-key smoke** — send a real prompt against the live model and verify the WORLD (a file on disk, a non-empty assistant turn), not the agent's self-report. This proves the actual product works, which a mock/keyless run structurally cannot. Key-gated: it self-skips without `DEEPSEEK_API_KEY` (see [the testing policy](../docs/testing.md) — inference is cheap here, so write many). **Exception — keyless-by-nature examples.** An example whose model is itself a mock/deterministic stand-in (no real provider) has no meaningful with-key smoke; the keyless smoke is the complete requirement. State the exception inline in the test. @@ -21,6 +21,6 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P |---|---|---| | `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | | `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified | -| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless; `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote | +| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written | See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 887bc18beb..1e3134ba2d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,12 +15,12 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigge ## coding-agent -The real thing: DeepSeek V4 + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant. +A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. The UI is a terminal readline REPL. -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. +Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. ## 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. +An agent demo 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 58ba92cd0e..58a5984301 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -1,12 +1,12 @@ # acp-agent example -The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio — drive it from Zed or any other ACP client. +The DeepSeek Harness SDK agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio — drive it from Zed or any other ACP client. ```sh 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), the swappable DeepSeek and bash backends, and the optional model-facing `subagent`/`subagent_fork`/`todo_write` tool entries. 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. +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), the swappable DeepSeek, bash, and filesystem backends, and the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries. 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 @@ -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/ui/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; both the agent's bash tools and the `read`/`write`/`edit` filesystem tools resolve relative paths against that per-session workspace (see the per-session `cwd` note in `packages/ui/acp` and [the per-session cwd RFC](../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), so the server can be launched anywhere and each session still acts on its own project directory. ## Snapshot tests (record-once / replay-deterministic) diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md new file mode 100644 index 0000000000..2644a2b112 --- /dev/null +++ b/examples/acp-agent/composition.md @@ -0,0 +1,67 @@ +<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand. + Run `pnpm run gen-doc-graphs` to regenerate. --> + +# ACP Agent App Composition + +The ACP demo exposes the same agent spine over JSON-RPC stdio, with no stdout logger and no pre-created agent; clients create sessions through the ACP bridge. + +```mermaid +flowchart LR + cfg["examples/acp-agent<br/>cordis.yml"] + plugin_acp_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_acp_llm_deepseek + plugin_acp_bash["bash<br/>@deepseek-ai/dsh-bash-local"] + cfg --> plugin_acp_bash + plugin_acp_acp_agent["acp-agent<br/>@deepseek-ai/dsh-acp-agent"] + cfg --> plugin_acp_acp_agent + plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_acp_subagent["subagent<br/>@deepseek-ai/dsh-subagent"] + cfg --> plugin_acp_subagent + plugin_acp_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_acp_subagent_spawn + plugin_acp_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_acp_subagent_fork + plugin_acp_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_acp_tool_subagent + plugin_acp_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_acp_tool_subagent_fork + plugin_acp_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_acp_tool_todo + plugin_acp_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"] + cfg --> plugin_acp_fs_local + plugin_acp_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_acp_fs_policy + plugin_acp_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_acp_tool_fs + plugin_acp_hooks_claude["hooks-claude<br/>@deepseek-ai/dsh-hooks-claude"] + cfg --> plugin_acp_hooks_claude + plugin_acp_hooks_codex["hooks-codex<br/>@deepseek-ai/dsh-hooks-codex"] + cfg --> plugin_acp_hooks_codex +``` + +| Plugin id | Package / module | +| --- | --- | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `acp-agent` | `@deepseek-ai/dsh-acp-agent` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `hooks-claude` | `@deepseek-ai/dsh-hooks-claude` | +| `hooks-codex` | `@deepseek-ai/dsh-hooks-codex` | + +Source config: [`examples/acp-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index f03cc49223..fd8a9dcad2 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,87 +1,33 @@ -# Snapshot-test REPLAY config: the acp-agent plugin tree with the model backend -# swapped to llm-replay (serves a recorded session JSONL — no API key, no -# network). The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. +# Snapshot-test REPLAY overlay: the SAME app tree as cordis.yml, derived from +# it by an include — the one difference is the model backend. A keyless replay +# run cannot boot the real adapter (llm-deepseek's apply() throws without +# DEEPSEEK_API_KEY), so the include patches the live tree at load time: the +# llm-deepseek entry is disabled by id, and the llm-replay entry (which serves +# a recorded session JSONL — no API key, no network) is inserted. Every other +# entry — the app, the bash executor, the fs/subagent/todo tools, both hook +# bridges, the system prompt — IS the live tree, so replay exercises exactly +# what ships and an app-shape change lands once, in cordis.yml. # -# Same app as cordis.yml (@deepseek-ai/dsh-acp-agent: the agent-core spine + -# JSONL persistence + the ACP bridge) — only the LLM backend differs: llm-replay -# here, llm-deepseek there. It can't reuse the real adapter because llm-deepseek's -# apply() throws without DEEPSEEK_API_KEY, killing a keyless replay run at boot. -# -# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (the app -# package omits it). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and -# an optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. - -# The replay adapter: short-circuits llm/stream with the recorded log's chunks, -# in place of llm-deepseek. -- id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. -- id: bash - name: '@deepseek-ai/dsh-bash-local' +# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. The replay +# fixture path comes from $DSH_SNAPSHOT_FILE (and an optional +# $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. stdout stays +# reserved for the ACP JSON-RPC protocol (the app package loads no stdout +# logger). Patches apply when the include loads the file — a one-shot replay +# boot, so the load-time-only patch semantics are exactly enough. +- id: base + name: '@cordisjs/plugin-include' config: - timeoutMs: 60000 - -# The ACP server app — identical to cordis.yml's entry. -- id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' - config: - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. - - Your tools are bash (plus bash_output/bash_kill for background tasks) - and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd. Check the [exit code: N] marker; - verify your work. Keep answers brief and factual. - - Use the subagent tool to delegate a focused, self-contained subtask to - a fresh child agent (it works in its own context and returns only its - final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. - -# The subagent seam + both in-process backends + two model-facing tools — -# identical to cordis.yml's wiring (only the LLM backend differs above): spawn -# and fork are each reachable via a dsh-tool-subagent bound to it with a distinct -# toolName (subagent → spawn, subagent_fork → fork). -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -# The model-facing todo_write tool — identical to cordis.yml's wiring, so a -# replayed todo_write tool call resolves to a real tool during snapshot replay. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' + path: ./cordis.yml + patches: + # The name is an assertion, not an override: the include skips the patch + # (warning if a logger exists) when the id points at a different plugin, + # so this can never disable the wrong entry. If cordis.yml ever RENAMES + # the id, the patch degrades to a skip — replay output stays correct + # (llm-replay still short-circuits the stream) but the stale patch and a + # futile keyless adapter entry linger until review catches them. + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 31d4d5429a..83ac07ce55 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,8 +1,9 @@ # 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. The swappable DeepSeek -# adapter and local bash executor, the ACP server app (@deepseek-ai/dsh-acp-agent), -# and the optional model-facing subagent/todo tools loaded below. +# adapter, local bash/filesystem executors, the ACP server app +# (@deepseek-ai/dsh-acp-agent), and the optional model-facing fs/subagent/todo +# tools loaded below. # # 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 @@ -22,9 +23,8 @@ - deepseek-v4-flash - deepseek-v4-pro -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. +# Local bash executor for agent-core's tool-bash schema (one of several tool +# stacks in this tree: filesystem, subagent, and todo_write load below). - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -38,27 +38,17 @@ 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. + # The persona: identity + behavior only, nothing about transports or + # tooling — tool guidance lives with each tool plugin (descriptions + + # prompt sections). {{model}} and {{cwd}} are prompt variables the agent + # loop resolves per session (every ACP session carries the client's cwd, + # so the persona can state the workspace). + persona: | + You are a coding assistant powered by the {{model}} model. Your working + directory is {{cwd}}. - Your tools are bash (plus bash_output/bash_kill for background tasks) - and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd. Check the [exit code: N] marker; - verify your work. Keep answers brief and factual. - - Use the subagent tool to delegate a focused, self-contained subtask to - a fresh child agent (it works in its own context and returns only its - final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. + Verify your work by running the code or tests. Keep answers brief and + factual. # The subagent seam + both in-process backends + two model-facing tools, as leaf # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh @@ -95,3 +85,45 @@ # session log (todo/write), surfaced to the ACP client as a `plan` update. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack: local provider, read-before-write/edit policy +# gate, then the model-facing read/write/edit tools. Relative filesystem paths +# resolve from the server launch cwd; the documented Zed setup launches this +# demo from the harness checkout with `pnpm --dir`. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +# The Claude Code hook bridge. `configPath` is PROCESS-LEVEL: it is read ONCE at +# load and the relative `./hooks.json` resolves against the ACP server's launch +# cwd, NOT each `session/new.cwd`. So a single `hooks.json` next to where the +# server starts applies to every session; a project-local, per-session hooks.json +# is NOT discovered (per-session config resolution is a TODO — see the bridge +# README). With no file present the parse fails-soft and the bridge registers +# nothing (a silent no-op). Hooks THEMSELVES run in the session cwd (the bridge +# passes it as the workdir); only WHERE the config is read from is process-level. +# stdout is the ACP JSON-RPC channel — the bridge's warnings go through ctx.logger +# (no exporter here), never to stdout. +- id: hooks-claude + name: '@deepseek-ai/dsh-hooks-claude' + config: + configPath: ./hooks.json + +# The Codex hook bridge, loaded alongside the Claude one. It reads its OWN config +# file (`./codex-hooks.json`, Codex's snake_case five-event dialect) — the two +# bridges cannot share one file, so each owns a distinct path. Same process-level +# read-once semantics and same fails-soft-when-absent contract: a launch cwd with +# no `codex-hooks.json` registers nothing (a silent no-op through ctx.logger, never +# stdout). The example ships both bridges so a scenario can exercise EITHER dialect +# end-to-end by seeding the matching file in its workspace/. +- id: hooks-codex + name: '@deepseek-ai/dsh-hooks-codex' + config: + configPath: ./codex-hooks.json diff --git a/examples/acp-agent/package.json b/examples/acp-agent/package.json index 499d21af99..8ee54f5650 100644 --- a/examples/acp-agent/package.json +++ b/examples/acp-agent/package.json @@ -1,6 +1,6 @@ { "name": "acp-agent-example", - "description": "Runnable demo: the coding agent as an ACP server over JSON-RPC stdio (Zed & other ACP editors)", + "description": "Runnable demo: an agent as an ACP server over JSON-RPC stdio (Zed & other ACP editors)", "private": true, "version": "0.0.1", "type": "module" diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index ee60a7d131..06ef962743 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -56,6 +56,9 @@ interface Spawned { stderr: string[] } +// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with +// hooks.e2e.ts and partly with snapshot-harness.ts. Extract one shared ACP test +// launcher before the TSX/env/permission-stub details drift again. function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { const child = spawn( process.execPath, diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 94f18e64d3..65d2dee9da 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -29,12 +29,22 @@ interface Scenario { name: string /** Whether the scenario drives at least one model turn (so a JSONL golden applies). */ hasModelTurn: boolean + /** + * Whether the run persists a comparable session log to diff against the + * `session.jsonl` fixture. Defaults to {@link hasModelTurn} (a model turn + * always produces a log worth comparing). Set it independently for a scenario + * that produces a non-trivial log WITHOUT a model turn — e.g. a prompt blocked + * by a `UserPromptSubmit` hook, which opens a `rejected` turn carrying `hook/*` + * events but never calls the model. + */ + comparesLog?: boolean /** * Whether `test:snapshot:record` regenerates this scenario's `session.jsonl` * from the LIVE API. `recorded` scenarios are model-driven and reproducible; * `authored` scenarios (a hand-written `replay.override.json` sidecar drives * replay — e.g. a provider error or a cancel, which the live API can't be - * coaxed into deterministically) are NEVER re-recorded. + * coaxed into deterministically — or a deterministic hook scenario whose + * derived empty script needs no sidecar) are NEVER re-recorded. */ recorded: boolean /** @@ -52,8 +62,15 @@ const SCENARIOS: Scenario[] = [ { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, { name: 'text-turn', hasModelTurn: true, recorded: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'workspace-edit', hasModelTurn: true, recorded: true }, + { name: 'fs-read', hasModelTurn: true, recorded: true }, + { name: 'fs-write', hasModelTurn: true, recorded: true }, + { name: 'fs-edit', hasModelTurn: true, recorded: true }, + { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, + { name: 'fs-read-window', hasModelTurn: true, recorded: true }, + { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false }, @@ -61,6 +78,45 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + // Hook matrix — one scenario per hook point × its headline Decision outcome, + // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in + // workspace/). The block scenarios need no model call: a UserPromptSubmit hook + // blocks the prompt before any step runs (keyless, authored — the derived + // script is empty so no sidecar), yet persists a `rejected` turn carrying + // `hook/*` events, so their logs ARE compared. Every other point fires a real + // seam mid-turn, so its transcript is recorded WITH the hook active. + { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, + { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, + // The mid-turn seams fire during a real model turn, so each is recorded WITH + // its hook active (the model's reaction to a deny/block/force-continue is part + // of the captured transcript). The Codex bridge exercises the same seams in its + // own snake_case dialect. + // + // Two hook points are deliberately NOT snapshotted, and stay on the bridges' + // unit coverage (`bridge.spec.ts` / `coverage.spec.ts`) instead: + // - SessionStart and SubagentStart inject context through a detached, + // best-effort `void runPoint(...).then(agent.inject())` with no turn + // binding, so the resulting `context/message` races the work it precedes + // and lands at a nondeterministic log position — a recorded golden does not + // even reproduce on its own replay. + // - SubagentStop is observe-only with no turn and no injection, so it writes + // NOTHING to the transcript — a golden would be byte-identical to the + // no-hook run and could never be proven to fail. + // See the hook-snapshot-matrix RFC for the full rationale. + { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true }, + { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true }, + { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true }, + // TODO(hook-snapshot-noise): re-record the PostToolUse block fixtures with a + // self-limiting prompt or hook so one rejected result proves the seam without + // repeated block/retry cycles in the committed JSONL. + { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true }, + { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true }, + { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true }, + { name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true }, + { name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true }, + { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true }, + { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true }, + { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true }, ] /** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */ @@ -139,12 +195,15 @@ for (const scenario of SCENARIOS) { await expect(normalizeStdout(result.rawStdout, ctx)) .toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl')) - if (scenario.hasModelTurn) { + // A model turn always produces a log worth comparing; a hook scenario can + // produce one without a model turn (a `rejected` turn carrying `hook/*`). + const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn + if (comparesLog) { // The harvested logs (primary-first) must match their committed fixtures // 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS // OWN volatile values — the live run's via `ctx`, the committed fixture's // via its own header (a committed file cannot share the live run's ids). - expect(result.sessionLogs.length, 'a model scenario must persist a session log').toBe(childSessions + 1) + expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] for (let i = 0; i < fixtureFiles.length; i++) { const harvested = (result.sessionLogs[i] as HarvestedLog).content diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts new file mode 100644 index 0000000000..bdb800186a --- /dev/null +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -0,0 +1,122 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { Readable, Writable } from 'node:stream' +import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' + +/** + * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent + * subprocess and the REAL model. The example `cordis.yml` loads `dsh-hooks-claude` + * with a PROCESS-LEVEL `configPath` of `./hooks.json`, resolved once at load + * against the ACP server's launch cwd (NOT per-session); this test sets that + * launch cwd to the temp workspace and writes a `hooks.json` there with a + * PreToolUse hook that BLOCKS every bash command, then asks the live model to + * write a file — and verifies the WORLD (the file never appears on disk), + * proving the hook actually intercepted execution rather than the agent merely + * claiming it couldn't. (The hook itself then runs in the session cwd.) + * Key-gated; owns and disposes its subprocess. + * + * A keyless companion lives in acp.e2e.ts (stdout purity + session/new); the + * full hook-fires-end-to-end transcript is the keyless `hook-cc-promptsubmit-block` + * snapshot scenario. This one closes the "green plumbing, broken product" gap: + * only a real model deciding to call bash exercises the PreToolUse seam live. + */ + +const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +interface Spawned { + child: ChildProcessWithoutNullStreams + client: ClientSideConnection + updates: SessionNotification['update'][] + stderr: string[] +} + +function spawnAcpAgent(cwd: string): Spawned { + const child = spawn( + process.execPath, + ['--import', tsxLoader, binScript, configPath], + { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, + ) + const stderr: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderr.push(chunk)) + + const updates: SessionNotification['update'][] = [] + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream<Uint8Array>, + Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise<void> { + updates.push(params.update) + return Promise.resolve() + }, + requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + const client = new ClientSideConnection(makeClient, stream) + return { child, client, updates, stderr } +} + +let spawned: Spawned | undefined +let workdir: string | undefined + +afterEach(async () => { + if (spawned) { + spawned.child.kill('SIGKILL') + spawned = undefined + } + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { + it('denies every bash command, so the requested file is never written (verified on disk)', async () => { + workdir = await mkdtemp(join(tmpdir(), 'acp-hooks-e2e-')) + // A PreToolUse hook that blocks EVERY tool (exit 2, no matcher = match-all). + // The session cwd is `workdir`, and the bridge resolves `./hooks.json` from + // the process cwd (the launch dir = workdir), so this is the config it loads. + await writeFile(join(workdir, 'hooks.json'), JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, + })) + + spawned = spawnAcpAgent(workdir) + const { client, updates } = spawned + + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) + + const res = await client.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Use the bash tool to write the exact text HOOK_FAIL into a file named proof.txt in the current directory. Then stop.' }], + }) + // The turn completes normally (the block is a tool-result error fed back to + // the model, not a turn failure). + expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + + // Verify the WORLD: the hook denied execution, so the file must NOT exist — + // a keyword probe a "cheating" agent could fake in prose cannot pass this. + await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() + + // The client still saw a tool_call stream (the model TRIED), and its result + // carried the hook's block reason back as an error. + const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call' || u.sessionUpdate === 'tool_call_update') + expect(toolCalls.length).toBeGreaterThan(0) + }, 180_000) +}) diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index b220344bb9..bfe29af8a5 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -90,4 +90,21 @@ describe('normalizeSessionLog', () => { const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx) expect(out).toContain('{{sessionId}}') }) + + it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => { + const ev = JSON.stringify({ + type: 'hook/result', seq: 2, time: 5, + data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 }, + }) + const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) + expect(out).toContain('"durationMs":0') + expect(out).not.toContain('37') + expect(out).toContain('"decision":"block"') // the decision is the behavior — kept + }) + + it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => { + const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } }) + const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) + expect(out).toContain('"durationMs":88') + }) }) diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index db0d493535..8150057fa4 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -8,7 +8,8 @@ * Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp` * cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header); * JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event - * `time` (epoch ms) and header `createdAt` → 0. NOT scrubbed: the log's `seq` + * `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's + * `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq` * (deterministic — `seq = log.length`, part of the event-log contract). * * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. @@ -97,6 +98,13 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri } else if ('time' in record) { // Event line: zero the epoch-ms timestamp; keep seq (deterministic). record.time = 0 + // A hook/result carries the hook's wall-clock runtime (`data.durationMs`), + // which is run-to-run noise like `time` — zero it so the golden reflects + // the hook's decision/exit, not how long the shell took. + if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') { + const data = record.data as Record<string, unknown> + if ('durationMs' in data) data.durationMs = 0 + } } return scrubValue(record, ctx) as Record<string, unknown> }) diff --git a/examples/acp-agent/tests/snapshots/fs-edit/input.json b/examples/acp-agent/tests/snapshots/fs-edit/input.json new file mode 100644 index 0000000000..1455aa373c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl new file mode 100644 index 0000000000..d491e4cf0f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -0,0 +1,131 @@ +{"type":"session","version":0,"id":"554ed85e-1fa3-4791-b4a2-9256b53f8add","createdAt":1783069537397,"cwd":"/tmp/acp-snap-cwd-qAWDep"} +{"type":"turn/start","seq":0,"time":1783069537400,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069537400,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069537401,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069537851,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069537851,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1783069537974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1783069538002,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":7,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":8,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":9,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":10,"time":1783069538035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" config"}}} +{"type":"assistant/chunk","seq":11,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":12,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":13,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":14,"time":1783069538132,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783069538132,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":16,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":17,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":19,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":20,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":21,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":22,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":23,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":24,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":26,"time":1783069538232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783069538298,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the config.txt file."}}}} +{"type":"assistant/chunk","seq":28,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} +{"type":"assistant/chunk","seq":29,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":55,"cacheReadTokens":2176,"reasoningTokens":10}}}} +{"type":"assistant/chunk","seq":30,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":31,"time":1783069538301,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the config.txt file."},{"type":"tool-call","id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":55,"cacheReadTokens":2176,"reasoningTokens":10}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} +{"type":"tool/call","seq":32,"time":1783069538301,"data":{"turn":1,"step":1,"callId":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} +{"type":"tool/result","seq":33,"time":1783069538305,"data":{"turn":1,"step":1,"callId":"call_00_MkPefqOY8sRQIkux83391414","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-qAWDep/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783069538306,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":35,"time":1783069538306,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":36,"time":1783069539033,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":37,"time":1783069539034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":38,"time":1783069539143,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":39,"time":1783069539167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":40,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":42,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":43,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" literal"}}} +{"type":"assistant/chunk","seq":44,"time":1783069539206,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} +{"type":"assistant/chunk","seq":45,"time":1783069539206,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":46,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} +{"type":"assistant/chunk","seq":47,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":49,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":50,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RE"}}} +{"type":"assistant/chunk","seq":51,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LEASE"}}} +{"type":"assistant/chunk","seq":52,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":54,"time":1783069539242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1783069539242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":56,"time":1783069539276,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":57,"time":1783069539276,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":58,"time":1783069539380,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":59,"time":1783069539380,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":60,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":61,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":63,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":64,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1783069539415,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":66,"time":1783069539449,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1783069539450,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":68,"time":1783069539450,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":69,"time":1783069539483,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1783069539518,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":71,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":73,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":74,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":76,"time":1783069539553,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":77,"time":1783069539554,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"DEBUG"}}} +{"type":"assistant/chunk","seq":78,"time":1783069539555,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1783069539621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":80,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":82,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":83,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":85,"time":1783069539662,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"RE"}}} +{"type":"assistant/chunk","seq":87,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"LEASE"}}} +{"type":"assistant/chunk","seq":88,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783069539692,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":90,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} +{"type":"assistant/chunk","seq":91,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} +{"type":"assistant/chunk","seq":92,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":241,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":93,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":94,"time":1783069539761,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":241,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":21}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"tool/call","seq":95,"time":1783069539762,"data":{"turn":1,"step":2,"callId":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} +{"type":"tool/result","seq":96,"time":1783069539768,"data":{"turn":1,"step":2,"callId":"call_00_BwdjVI05cT0dvHSaziZp0350","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-qAWDep/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1783069539768,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":98,"time":1783069539768,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":99,"time":1783069540733,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":100,"time":1783069540734,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":101,"time":1783069540860,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":102,"time":1783069540894,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":103,"time":1783069540894,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":104,"time":1783069540895,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" updated"}}} +{"type":"assistant/chunk","seq":105,"time":1783069540895,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":106,"time":1783069540930,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":107,"time":1783069540964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":108,"time":1783069540998,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":109,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":110,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":111,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":112,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":113,"time":1783069541033,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":114,"time":1783069541033,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":115,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":116,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":117,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":118,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1783069541067,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":120,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":121,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":122,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":123,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been updated. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":124,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":125,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":244,"outputTokens":23,"cacheReadTokens":2304,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":126,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":127,"time":1783069541069,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file has been updated. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":244,"outputTokens":23,"cacheReadTokens":2304,"reasoningTokens":20}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126],"surfaceOp":"append"} +{"type":"step/end","seq":128,"time":1783069541070,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":129,"time":1783069541070,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl new file mode 100644 index 0000000000..c2a8fe005c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -0,0 +1,60 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" config"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_MkPefqOY8sRQIkux83391414","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_MkPefqOY8sRQIkux83391414","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" literal"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" text"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"DEBUG"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LEASE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_BwdjVI05cT0dvHSaziZp0350","title":"Edit config.txt","kind":"edit","status":"in_progress","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_BwdjVI05cT0dvHSaziZp0350","status":"completed","content":[{"type":"diff","path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}],"title":"Edit config.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" been"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" updated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt new file mode 100644 index 0000000000..267876a5af --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt @@ -0,0 +1,2 @@ +mode=DEBUG +level=info diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json b/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json new file mode 100644 index 0000000000..c44d44c675 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl new file mode 100644 index 0000000000..ee3ca84d02 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -0,0 +1,370 @@ +{"type":"session","version":0,"id":"9e9f6ebd-684e-442e-bfee-d6aebb65ec67","createdAt":1783069553965,"cwd":"/tmp/acp-snap-cwd-owjbfU"} +{"type":"turn/start","seq":0,"time":1783069553968,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069553968,"data":{"content":[{"type":"text","text":"Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069553969,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069554380,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069554380,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069554505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069554539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783069554577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":12,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":13,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":15,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"blue"}}} +{"type":"assistant/chunk","seq":17,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"green"}}} +{"type":"assistant/chunk","seq":21,"time":1783069554649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783069554649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":23,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" settings"}}} +{"type":"assistant/chunk","seq":24,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":26,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":27,"time":1783069554685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":28,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":29,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":30,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":32,"time":1783069554719,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":33,"time":1783069554719,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":34,"time":1783069554753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":35,"time":1783069554753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783069554826,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783069554826,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783069554856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783069554857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783069554857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":41,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":42,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783069554929,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"settings"}}} +{"type":"assistant/chunk","seq":46,"time":1783069554930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":47,"time":1783069554930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783069554964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":49,"time":1783069554965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783069554965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":51,"time":1783069554995,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":52,"time":1783069554995,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069554996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":54,"time":1783069554996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783069555030,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"blue"}}} +{"type":"assistant/chunk","seq":56,"time":1783069555031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":58,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":60,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":61,"time":1783069555105,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069555105,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783069555106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783069555106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"green"}}} +{"type":"assistant/chunk","seq":65,"time":1783069555139,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":66,"time":1783069555139,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":67,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first. Let me do that."}}}} +{"type":"assistant/chunk","seq":68,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} +{"type":"assistant/chunk","seq":69,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":111,"cacheReadTokens":2176,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":70,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":71,"time":1783069555211,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first. Let me do that."},{"type":"tool-call","id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":126,"outputTokens":111,"cacheReadTokens":2176,"reasoningTokens":32}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70],"surfaceOp":"append"} +{"type":"tool/call","seq":72,"time":1783069555211,"data":{"turn":1,"step":1,"callId":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} +{"type":"tool/result","seq":73,"time":1783069555215,"data":{"turn":1,"step":1,"callId":"call_00_vCcG7c6T2vNO29dXgpkK5485","content":[{"type":"text","text":"Error: edit requires reading \"/tmp/acp-snap-cwd-owjbfU/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[72],"surfaceOp":"append"} +{"type":"step/end","seq":74,"time":1783069555215,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":75,"time":1783069555215,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":76,"time":1783069555951,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":77,"time":1783069555951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":78,"time":1783069556045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":79,"time":1783069556079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":80,"time":1783069556080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":81,"time":1783069556080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":82,"time":1783069556113,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":83,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":84,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":85,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" due"}}} +{"type":"assistant/chunk","seq":86,"time":1783069556147,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":87,"time":1783069556148,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":88,"time":1783069556148,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} +{"type":"assistant/chunk","seq":89,"time":1783069556189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} +{"type":"assistant/chunk","seq":90,"time":1783069556189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} +{"type":"assistant/chunk","seq":91,"time":1783069556190,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":92,"time":1783069556190,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":93,"time":1783069556215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":94,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":95,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":96,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":97,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783069556249,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":99,"time":1783069556250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":100,"time":1783069556250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":101,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":102,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":103,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":104,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" However"}}} +{"type":"assistant/chunk","seq":106,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":107,"time":1783069556318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":108,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":109,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":110,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" itself"}}} +{"type":"assistant/chunk","seq":111,"time":1783069556355,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":112,"time":1783069556388,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":113,"time":1783069556427,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":114,"time":1783069556428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":115,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":117,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":118,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":119,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":120,"time":1783069556463,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":121,"time":1783069556494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":122,"time":1783069556494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":123,"time":1783069556531,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":124,"time":1783069556531,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":125,"time":1783069556564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":126,"time":1783069556565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":127,"time":1783069556599,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" editing"}}} +{"type":"assistant/chunk","seq":128,"time":1783069556632,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":129,"time":1783069556633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":130,"time":1783069556667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":131,"time":1783069556702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":132,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":133,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":134,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":135,"time":1783069556737,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":136,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":137,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":138,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":139,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":140,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":141,"time":1783069556772,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":142,"time":1783069556807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \n\n"}}} +{"type":"assistant/chunk","seq":143,"time":1783069556808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} +{"type":"assistant/chunk","seq":144,"time":1783069556808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":145,"time":1783069556809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" looking"}}} +{"type":"assistant/chunk","seq":146,"time":1783069556841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":147,"time":1783069556841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":148,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":149,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":150,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":151,"time":1783069556878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":152,"time":1783069556879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":153,"time":1783069556879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"edit"}}} +{"type":"assistant/chunk","seq":154,"time":1783069556910,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":155,"time":1783069556911,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":156,"time":1783069556911,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ..."}}} +{"type":"assistant/chunk","seq":157,"time":1783069556944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":158,"time":1783069556945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1783069556979,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":160,"time":1783069556980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":161,"time":1783069556980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" might"}}} +{"type":"assistant/chunk","seq":162,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":163,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":164,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":165,"time":1783069557051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" enforcement"}}} +{"type":"assistant/chunk","seq":166,"time":1783069557051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":167,"time":1783069557085,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":168,"time":1783069557124,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":169,"time":1783069557125,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":170,"time":1783069557125,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bypass"}}} +{"type":"assistant/chunk","seq":171,"time":1783069557160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":172,"time":1783069557160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":173,"time":1783069557201,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":174,"time":1783069557202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":175,"time":1783069557202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":176,"time":1783069557227,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":177,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":178,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quite"}}} +{"type":"assistant/chunk","seq":179,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" strict"}}} +{"type":"assistant/chunk","seq":180,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":181,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":182,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":183,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":184,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":185,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":186,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":187,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":188,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":189,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Immediately"}}} +{"type":"assistant/chunk","seq":190,"time":1783069557296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":191,"time":1783069557331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":192,"time":1783069557332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":193,"time":1783069557332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":194,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"...\"\n\n"}}} +{"type":"assistant/chunk","seq":195,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"I"}}} +{"type":"assistant/chunk","seq":196,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":197,"time":1783069557413,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":198,"time":1783069557414,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} +{"type":"assistant/chunk","seq":199,"time":1783069557436,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":200,"time":1783069557437,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":201,"time":1783069557469,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" constraints"}}} +{"type":"assistant/chunk","seq":202,"time":1783069557470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":203,"time":1783069557503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":204,"time":1783069557504,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":205,"time":1783069557504,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":206,"time":1783069557538,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" won"}}} +{"type":"assistant/chunk","seq":207,"time":1783069557573,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":208,"time":1783069557573,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":209,"time":1783069557606,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":210,"time":1783069557607,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":211,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":212,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":213,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":214,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":215,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":216,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":217,"time":1783069557678,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inform"}}} +{"type":"assistant/chunk","seq":218,"time":1783069557710,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":219,"time":1783069557711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":220,"time":1783069557711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":221,"time":1783069557744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":222,"time":1783069557778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":223,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Wait"}}} +{"type":"assistant/chunk","seq":224,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":225,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} +{"type":"assistant/chunk","seq":226,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":227,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" re"}}} +{"type":"assistant/chunk","seq":228,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-read"}}} +{"type":"assistant/chunk","seq":229,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":230,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":231,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":232,"time":1783069557819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":233,"time":1783069557819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":234,"time":1783069557851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":235,"time":1783069557852,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":236,"time":1783069557889,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":237,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":238,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":239,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":240,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":241,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":242,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":243,"time":1783069557954,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":244,"time":1783069557955,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":245,"time":1783069557991,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":246,"time":1783069558025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":247,"time":1783069558059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" specifically"}}} +{"type":"assistant/chunk","seq":248,"time":1783069558060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":249,"time":1783069558101,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":250,"time":1783069558102,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":251,"time":1783069558102,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":252,"time":1783069558132,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":253,"time":1783069558133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":254,"time":1783069558168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":255,"time":1783069558168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":256,"time":1783069558169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":257,"time":1783069558169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":258,"time":1783069558199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":259,"time":1783069558199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":260,"time":1783069558200,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" documentation"}}} +{"type":"assistant/chunk","seq":261,"time":1783069558234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":262,"time":1783069558235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":263,"time":1783069558270,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Read"}}} +{"type":"assistant/chunk","seq":264,"time":1783069558303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":265,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":266,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":267,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":268,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"the"}}} +{"type":"assistant/chunk","seq":269,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" default"}}} +{"type":"assistant/chunk","seq":270,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} +{"type":"assistant/chunk","seq":271,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} +{"type":"assistant/chunk","seq":272,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} +{"type":"assistant/chunk","seq":273,"time":1783069558339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":274,"time":1783069558371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":275,"time":1783069558372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":276,"time":1783069558372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" unless"}}} +{"type":"assistant/chunk","seq":277,"time":1783069558406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" you"}}} +{"type":"assistant/chunk","seq":278,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":279,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":280,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":281,"time":1783069558441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edited"}}} +{"type":"assistant/chunk","seq":282,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":283,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":284,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":285,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":286,"time":1783069558478,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":287,"time":1783069558479,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":288,"time":1783069558479,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":289,"time":1783069558515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":290,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":291,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":292,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":293,"time":1783069558584,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":294,"time":1783069558585,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" having"}}} +{"type":"assistant/chunk","seq":295,"time":1783069558616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":296,"time":1783069558616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":297,"time":1783069558617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":298,"time":1783069558617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":299,"time":1783069558650,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":300,"time":1783069558650,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":301,"time":1783069558684,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" system"}}} +{"type":"assistant/chunk","seq":302,"time":1783069558721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prevented"}}} +{"type":"assistant/chunk","seq":303,"time":1783069558757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":304,"time":1783069558791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":305,"time":1783069558791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":306,"time":1783069558826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":307,"time":1783069558826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":308,"time":1783069558860,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":309,"time":1783069558894,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happened"}}} +{"type":"assistant/chunk","seq":310,"time":1783069558894,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":311,"time":1783069558895,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":312,"time":1783069558895,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":313,"time":1783069558928,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":314,"time":1783069558928,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":315,"time":1783069558929,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"'s"}}} +{"type":"assistant/chunk","seq":316,"time":1783069558960,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" files"}}} +{"type":"assistant/chunk","seq":317,"time":1783069558998,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ystem"}}} +{"type":"assistant/chunk","seq":318,"time":1783069558999,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":319,"time":1783069558999,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":320,"time":1783069559032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" reading"}}} +{"type":"assistant/chunk","seq":321,"time":1783069559065,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":322,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":323,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" first"}}} +{"type":"assistant/chunk","seq":324,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":325,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":326,"time":1783069559099,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":327,"time":1783069559100,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":328,"time":1783069559135,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":329,"time":1783069559169,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" without"}}} +{"type":"assistant/chunk","seq":330,"time":1783069559207,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" a"}}} +{"type":"assistant/chunk","seq":331,"time":1783069559242,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" prior"}}} +{"type":"assistant/chunk","seq":332,"time":1783069559242,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":333,"time":1783069559243,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":334,"time":1783069559243,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":335,"time":1783069559277,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" attempted"}}} +{"type":"assistant/chunk","seq":336,"time":1783069559311,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":337,"time":1783069559347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":338,"time":1783069559347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" as"}}} +{"type":"assistant/chunk","seq":339,"time":1783069559380,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} +{"type":"assistant/chunk","seq":340,"time":1783069559381,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":341,"time":1783069559414,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" but"}}} +{"type":"assistant/chunk","seq":342,"time":1783069559415,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":343,"time":1783069559415,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" system"}}} +{"type":"assistant/chunk","seq":344,"time":1783069559449,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":345,"time":1783069559450,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} +{"type":"assistant/chunk","seq":346,"time":1783069559483,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":347,"time":1783069559484,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Would"}}} +{"type":"assistant/chunk","seq":348,"time":1783069559516,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" you"}}} +{"type":"assistant/chunk","seq":349,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" like"}}} +{"type":"assistant/chunk","seq":350,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" me"}}} +{"type":"assistant/chunk","seq":351,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":352,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":353,"time":1783069559552,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":354,"time":1783069559552,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":355,"time":1783069559553,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" first"}}} +{"type":"assistant/chunk","seq":356,"time":1783069559553,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":357,"time":1783069559586,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" then"}}} +{"type":"assistant/chunk","seq":358,"time":1783069559587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" perform"}}} +{"type":"assistant/chunk","seq":359,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":360,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":361,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"?"}}} +{"type":"assistant/chunk","seq":362,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the fs-policy. But the user explicitly told me not to use the read tool. However, the edit tool itself requires that I read the file first. Let me try reading the file first then editing it, but the user said \"Do NOT use the read tool.\" \n\nActually, looking more carefully, the error says \"edit requires reading ... first\" - this might be a policy enforcement that I can't bypass. But the user's instruction is quite strict: \"Do NOT use the read tool. Immediately use the edit tool...\"\n\nI can't comply with both constraints. The edit tool won't work without a prior read. Let me just inform the user of this.\n\nWait, let me re-read the instructions more carefully. The user says \"Do NOT use the read tool.\" - they specifically say don't use the read tool. But the edit tool documentation says \"Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\"\n\nSo I can't use edit without having read the file first. The system prevented me. I'll report what happened."}}}} +{"type":"assistant/chunk","seq":363,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The edit tool's filesystem policy requires reading the file first, so I cannot edit without a prior read. I attempted the edit as instructed, but the system rejected it.\n\nWould you like me to read the file first and then perform the edit?"}}}} +{"type":"assistant/chunk","seq":364,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":272,"outputTokens":285,"cacheReadTokens":2176,"reasoningTokens":234}}}} +{"type":"assistant/chunk","seq":365,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":366,"time":1783069559624,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the fs-policy. But the user explicitly told me not to use the read tool. However, the edit tool itself requires that I read the file first. Let me try reading the file first then editing it, but the user said \"Do NOT use the read tool.\" \n\nActually, looking more carefully, the error says \"edit requires reading ... first\" - this might be a policy enforcement that I can't bypass. But the user's instruction is quite strict: \"Do NOT use the read tool. Immediately use the edit tool...\"\n\nI can't comply with both constraints. The edit tool won't work without a prior read. Let me just inform the user of this.\n\nWait, let me re-read the instructions more carefully. The user says \"Do NOT use the read tool.\" - they specifically say don't use the read tool. But the edit tool documentation says \"Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\"\n\nSo I can't use edit without having read the file first. The system prevented me. I'll report what happened."},{"type":"text","text":"The edit tool's filesystem policy requires reading the file first, so I cannot edit without a prior read. I attempted the edit as instructed, but the system rejected it.\n\nWould you like me to read the file first and then perform the edit?"}],"usage":{"inputTokens":272,"outputTokens":285,"cacheReadTokens":2176,"reasoningTokens":234}},"sourceEventSeqs":[76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365],"surfaceOp":"append"} +{"type":"step/end","seq":367,"time":1783069559624,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":368,"time":1783069559624,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl new file mode 100644 index 0000000000..5d37216325 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -0,0 +1,321 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"blue"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"green"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" settings"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vCcG7c6T2vNO29dXgpkK5485","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vCcG7c6T2vNO29dXgpkK5485","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" due"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-p"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"olicy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicitly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" told"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" However"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" itself"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" editing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" NOT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" looking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ..."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" might"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" enforcement"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bypass"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" strict"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" NOT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Immediately"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"...\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" constraints"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" won"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" work"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prior"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inform"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Wait"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" re"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" NOT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" they"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specifically"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" say"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" don"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" documentation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" default"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-p"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"olicy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"),"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" unless"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" you"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edited"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" having"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prevented"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happened"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" files"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ystem"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" prior"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" attempted"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Would"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" you"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" like"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" perform"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"?"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt b/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt new file mode 100644 index 0000000000..5686506464 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt @@ -0,0 +1 @@ +color: blue diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/input.json b/examples/acp-agent/tests/snapshots/fs-read-window/input.json new file mode 100644 index 0000000000..a2f42ac808 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl new file mode 100644 index 0000000000..bad62dda0f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -0,0 +1,126 @@ +{"type":"session","version":0,"id":"41e2e29c-6ca8-4adf-91b1-8927c9a066af","createdAt":1783069549203,"cwd":"/tmp/acp-snap-cwd-ogYesF"} +{"type":"turn/start","seq":0,"time":1783069549211,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069549212,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069549213,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069549785,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069549786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069549934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069549973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069549973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069549974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069549974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1783069550006,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":11,"time":1783069550007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":12,"time":1783069550007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":13,"time":1783069550044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":14,"time":1783069550045,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":15,"time":1783069550045,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":16,"time":1783069550073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":17,"time":1783069550107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1783069550141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":19,"time":1783069550174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":20,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"7"}}} +{"type":"assistant/chunk","seq":21,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":22,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":23,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":24,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":25,"time":1783069550209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":26,"time":1783069550209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":27,"time":1783069550210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":28,"time":1783069550210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":29,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":30,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":31,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" offset"}}} +{"type":"assistant/chunk","seq":33,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} +{"type":"assistant/chunk","seq":34,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":35,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":36,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" limit"}}} +{"type":"assistant/chunk","seq":37,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} +{"type":"assistant/chunk","seq":38,"time":1783069550284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":39,"time":1783069550284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783069550387,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":41,"time":1783069550387,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":42,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":43,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":45,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":46,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":48,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"big"}}} +{"type":"assistant/chunk","seq":50,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":51,"time":1783069550490,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783069550518,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":53,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"offset"}}} +{"type":"assistant/chunk","seq":55,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":57,"time":1783069550553,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"5"}}} +{"type":"assistant/chunk","seq":58,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":59,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"limit"}}} +{"type":"assistant/chunk","seq":61,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783069550663,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"4"}}} +{"type":"assistant/chunk","seq":64,"time":1783069550697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":65,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read lines 5-8 (5,6,7,8) of big.txt using the read tool with offset=5 and limit=4."}}}} +{"type":"assistant/chunk","seq":66,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} +{"type":"assistant/chunk","seq":67,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":113,"cacheReadTokens":2176,"reasoningTokens":36}}}} +{"type":"assistant/chunk","seq":68,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1783069550741,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read lines 5-8 (5,6,7,8) of big.txt using the read tool with offset=5 and limit=4."},{"type":"tool-call","id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":119,"outputTokens":113,"cacheReadTokens":2176,"reasoningTokens":36}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1783069550742,"data":{"turn":1,"step":1,"callId":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} +{"type":"tool/result","seq":71,"time":1783069550746,"data":{"turn":1,"step":1,"callId":"call_00_pwDbbXpPbcf7LOV3jXa47203","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-ogYesF/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1783069550747,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1783069550747,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1783069551362,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":75,"time":1783069551362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":76,"time":1783069551492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":77,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":78,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":79,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":80,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":81,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":82,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":84,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":85,"time":1783069551560,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":86,"time":1783069551560,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":87,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":88,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":89,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":90,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":91,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1783069551659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":98,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":99,"time":1783069551690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} +{"type":"assistant/chunk","seq":100,"time":1783069551691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" done"}}} +{"type":"assistant/chunk","seq":101,"time":1783069551691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":102,"time":1783069551726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":103,"time":1783069551727,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":104,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":105,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":106,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":107,"time":1783069551790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":108,"time":1783069551790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":109,"time":1783069551791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":110,"time":1783069551791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":111,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":113,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":114,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":115,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":116,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":117,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":118,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done the read, now I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":119,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":120,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":313,"outputTokens":43,"cacheReadTokens":2176,"reasoningTokens":40}}}} +{"type":"assistant/chunk","seq":121,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":122,"time":1783069551860,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done the read, now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":313,"outputTokens":43,"cacheReadTokens":2176,"reasoningTokens":40}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121],"surfaceOp":"append"} +{"type":"step/end","seq":123,"time":1783069551860,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":124,"time":1783069551861,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl new file mode 100644 index 0000000000..f24c053d50 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -0,0 +1,83 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"6"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"7"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" offset"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" limit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_pwDbbXpPbcf7LOV3jXa47203","title":"Read big.txt (5 - 8)","kind":"read","status":"in_progress","locations":[{"path":"big.txt","line":5}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_pwDbbXpPbcf7LOV3jXa47203","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ve"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" done"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt new file mode 100644 index 0000000000..ae121a6980 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt @@ -0,0 +1,10 @@ +line one +line two +line three +line four +line five +line six +line seven +line eight +line nine +line ten diff --git a/examples/acp-agent/tests/snapshots/fs-read/input.json b/examples/acp-agent/tests/snapshots/fs-read/input.json new file mode 100644 index 0000000000..c8097b7246 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl new file mode 100644 index 0000000000..2a8d2d2640 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -0,0 +1,87 @@ +{"type":"session","version":0,"id":"93c4732a-4106-4056-ad0b-c9d314bbac92","createdAt":1783069528172,"cwd":"/tmp/acp-snap-cwd-mYfeWU"} +{"type":"turn/start","seq":0,"time":1783069528176,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069528176,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069528177,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069528725,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069528725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069528890,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1783069528924,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} +{"type":"assistant/chunk","seq":13,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":14,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":15,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":17,"time":1783069528959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1783069528959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":19,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"not"}}} +{"type":"assistant/chunk","seq":20,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":21,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":22,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":23,"time":1783069528993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":24,"time":1783069528993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1783069529025,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":26,"time":1783069529026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":27,"time":1783069529026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":28,"time":1783069529061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":29,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":30,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":31,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":32,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":33,"time":1783069529163,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":34,"time":1783069529163,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":35,"time":1783069529195,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":36,"time":1783069529195,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":38,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":39,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"gre"}}} +{"type":"assistant/chunk","seq":43,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"eting"}}} +{"type":"assistant/chunk","seq":44,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":45,"time":1783069529263,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783069529264,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":48,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":49,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":75,"cacheReadTokens":2176,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":50,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":51,"time":1783069529333,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":106,"outputTokens":75,"cacheReadTokens":2176,"reasoningTokens":29}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50],"surfaceOp":"append"} +{"type":"tool/call","seq":52,"time":1783069529333,"data":{"turn":1,"step":1,"callId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} +{"type":"tool/result","seq":53,"time":1783069529338,"data":{"turn":1,"step":1,"callId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-mYfeWU/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1783069529338,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":55,"time":1783069529338,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":56,"time":1783069530344,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":57,"time":1783069530344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":58,"time":1783069530439,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":59,"time":1783069530472,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":60,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":61,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":62,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":63,"time":1783069530503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":64,"time":1783069530503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":65,"time":1783069530533,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":66,"time":1783069530565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":67,"time":1783069530566,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":68,"time":1783069530597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":70,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":71,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":72,"time":1783069530629,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":73,"time":1783069530666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":74,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":75,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":76,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":78,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":79,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\". I just needed to read it and reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":80,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":81,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":240,"outputTokens":22,"cacheReadTokens":2176,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":82,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":83,"time":1783069530695,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"hello\". I just needed to read it and reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":240,"outputTokens":22,"cacheReadTokens":2176,"reasoningTokens":19}},"sourceEventSeqs":[56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} +{"type":"step/end","seq":84,"time":1783069530695,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":85,"time":1783069530696,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl new file mode 100644 index 0000000000..30076deeeb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -0,0 +1,55 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" greeting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"),"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt @@ -0,0 +1 @@ +hello diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json b/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json new file mode 100644 index 0000000000..de9237ea82 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize", "terminalOutput": true }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl new file mode 100644 index 0000000000..4ec738951c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl @@ -0,0 +1,94 @@ +{"type":"session","version":0,"id":"60f37569-c7d6-4275-9105-7b67fd4c8586","createdAt":1783069523946,"cwd":"/tmp/acp-snap-cwd-tMMuJX"} +{"type":"turn/start","seq":0,"time":1783069523951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069523951,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069523952,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069524432,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069524432,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069524538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783069524575,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783069524608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":12,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":13,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":15,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":16,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":18,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":19,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":20,"time":1783069524678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1783069524780,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1783069524780,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":23,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":24,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":26,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":28,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":30,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":31,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":32,"time":1783069524887,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":33,"time":1783069524888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":34,"time":1783069524888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":36,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":38,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783069524960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":42,"time":1783069524991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":43,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":44,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":45,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":46,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":48,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":49,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" terminal"}}} +{"type":"assistant/chunk","seq":50,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1783069525064,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":52,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":53,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2278,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":55,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":1783069525101,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}],"usage":{"inputTokens":2278,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":1783069525101,"data":{"turn":1,"step":1,"callId":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}} +{"type":"tool/result","seq":58,"time":1783069525115,"data":{"turn":1,"step":1,"callId":"call_00_sNkk1FV281UaJqU09vE20707","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1783069525116,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":60,"time":1783069525116,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":61,"time":1783069525842,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":62,"time":1783069525842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":63,"time":1783069525957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":64,"time":1783069525992,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":65,"time":1783069526025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":66,"time":1783069526025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":67,"time":1783069526026,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":68,"time":1783069526063,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":69,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} +{"type":"assistant/chunk","seq":70,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} +{"type":"assistant/chunk","seq":71,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":72,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":73,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":74,"time":1783069526096,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1783069526097,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":76,"time":1783069526133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":77,"time":1783069526133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":78,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":79,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":80,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":81,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":82,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":83,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":84,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":85,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":87,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":88,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":207,"outputTokens":24,"cacheReadTokens":2176,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":89,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":90,"time":1783069526170,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":207,"outputTokens":24,"cacheReadTokens":2176,"reasoningTokens":21}},"sourceEventSeqs":[61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89],"surfaceOp":"append"} +{"type":"step/end","seq":91,"time":1783069526171,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":92,"time":1783069526171,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl new file mode 100644 index 0000000000..1411855f29 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -0,0 +1,45 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_sNkk1FV281UaJqU09vE20707","title":"echo TERMINAL_OK","kind":"execute","status":"in_progress","rawInput":"echo TERMINAL_OK","content":[{"type":"content","content":{"type":"text","text":"Echo TERMINAL_OK to verify terminal"}},{"type":"terminal","terminalId":"call_00_sNkk1FV281UaJqU09vE20707"}],"_meta":{"terminal_info":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","cwd":"{{cwd}}"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sNkk1FV281UaJqU09vE20707","status":"completed","_meta":{"terminal_output":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","data":"TERMINAL_OK\n"},"terminal_exit":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","exit_code":0}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ran"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"TER"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"MIN"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json new file mode 100644 index 0000000000..585ec3ebed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl new file mode 100644 index 0000000000..8ad253a949 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -0,0 +1,124 @@ +{"type":"session","version":0,"id":"f283455e-a3d7-4b99-bf71-4e4494c6d71e","createdAt":1783082855218,"cwd":"/tmp/acp-snap-cwd-u64NRw"} +{"type":"turn/start","seq":0,"time":1783082855223,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783082855223,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783082855224,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783082855617,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783082855617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1783082855716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1783082855744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":7,"time":1783082855744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":8,"time":1783082855745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":9,"time":1783082855773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":10,"time":1783082855773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} +{"type":"assistant/chunk","seq":11,"time":1783082855774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":12,"time":1783082855826,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":13,"time":1783082855827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":14,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":15,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} +{"type":"assistant/chunk","seq":16,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directory"}}} +{"type":"assistant/chunk","seq":17,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":18,"time":1783082855918,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":19,"time":1783082855918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":20,"time":1783082855948,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":21,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":23,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":24,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":26,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":28,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":29,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1783082856010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":31,"time":1783082856065,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the data.txt file in the current directory."}}}} +{"type":"assistant/chunk","seq":32,"time":1783082856065,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":33,"time":1783082856066,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1783082856066,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":35,"time":1783082856068,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the data.txt file in the current directory."},{"type":"tool-call","id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"tool/call","seq":36,"time":1783082856068,"data":{"turn":1,"step":1,"callId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":37,"time":1783082856073,"data":{"turn":1,"step":1,"callId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-u64NRw/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1783082856073,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":39,"time":1783082856073,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":40,"time":1783082856584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":41,"time":1783082856584,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":42,"time":1783082856660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":43,"time":1783082856692,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":44,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":45,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":46,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":47,"time":1783082856724,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":48,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":49,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":50,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":51,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":52,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":53,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":54,"time":1783082856758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":55,"time":1783082856791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replaced"}}} +{"type":"assistant/chunk","seq":56,"time":1783082856792,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1783082856848,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1783082856848,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1783082856877,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1783082856878,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1783082856878,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":62,"time":1783082856906,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":63,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":65,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":66,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":67,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":68,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":69,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":70,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":72,"time":1783082856994,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":74,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"re"}}} +{"type":"assistant/chunk","seq":76,"time":1783082857023,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"placed"}}} +{"type":"assistant/chunk","seq":77,"time":1783082857024,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783082857052,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":79,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents with exactly the single line: replaced."}}}} +{"type":"assistant/chunk","seq":80,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":78,"cacheReadTokens":2176,"reasoningTokens":16}}}} +{"type":"assistant/chunk","seq":82,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1783082857087,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents with exactly the single line: replaced."},{"type":"tool-call","id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":239,"outputTokens":78,"cacheReadTokens":2176,"reasoningTokens":16}},"sourceEventSeqs":[40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1783082857087,"data":{"turn":1,"step":2,"callId":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} +{"type":"tool/result","seq":85,"time":1783082857093,"data":{"turn":1,"step":2,"callId":"call_00_kwKFkGfN8j2XfvK34T5R7663","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-u64NRw/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1783082857093,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":87,"time":1783082857094,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":88,"time":1783082857728,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":89,"time":1783082857729,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":90,"time":1783082857818,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":91,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":92,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":93,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" replaced"}}} +{"type":"assistant/chunk","seq":94,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":95,"time":1783082857878,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":96,"time":1783082857878,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":97,"time":1783082857907,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":98,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":99,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":100,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":101,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":102,"time":1783082857963,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":103,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":104,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":105,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":106,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":107,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":108,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":109,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":110,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":111,"time":1783082857970,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":112,"time":1783082857970,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":113,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":114,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":115,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":116,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been replaced with \"replaced\". Now I just need to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":117,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":118,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":234,"outputTokens":27,"cacheReadTokens":2304,"reasoningTokens":24}}}} +{"type":"assistant/chunk","seq":119,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":120,"time":1783082858001,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file has been replaced with \"replaced\". Now I just need to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":234,"outputTokens":27,"cacheReadTokens":2304,"reasoningTokens":24}},"sourceEventSeqs":[88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119],"surfaceOp":"append"} +{"type":"step/end","seq":121,"time":1783082858001,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":122,"time":1783082858001,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl new file mode 100644 index 0000000000..504483c7b9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -0,0 +1,63 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" data"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" current"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" directory"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entire"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replaced"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_kwKFkGfN8j2XfvK34T5R7663","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_kwKFkGfN8j2XfvK34T5R7663","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" been"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replaced"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt new file mode 100644 index 0000000000..b2745f6f48 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt @@ -0,0 +1 @@ +original contents diff --git a/examples/acp-agent/tests/snapshots/fs-write/input.json b/examples/acp-agent/tests/snapshots/fs-write/input.json new file mode 100644 index 0000000000..1512e93735 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl new file mode 100644 index 0000000000..ff637f94a1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -0,0 +1,96 @@ +{"type":"session","version":0,"id":"4be5b8f3-93b3-4830-a8ad-089dafb693c1","createdAt":1783082851377,"cwd":"/tmp/acp-snap-cwd-bol9fl"} +{"type":"turn/start","seq":0,"time":1783082851381,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783082851382,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783082851383,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783082851775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783082851775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783082851949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783082851980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":10,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783082851982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1783082852010,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} +{"type":"assistant/chunk","seq":13,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} +{"type":"assistant/chunk","seq":14,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783082852043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1783082852044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} +{"type":"assistant/chunk","seq":18,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":20,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} +{"type":"assistant/chunk","seq":21,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":23,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":25,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":26,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1783082852108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":28,"time":1783082852108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":29,"time":1783082852140,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":31,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":32,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":33,"time":1783082852170,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":34,"time":1783082852170,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1783082852229,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783082852230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":40,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":41,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783082852317,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"notes"}}} +{"type":"assistant/chunk","seq":45,"time":1783082852318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":46,"time":1783082852318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":48,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":50,"time":1783082852387,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":52,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"hello"}}} +{"type":"assistant/chunk","seq":54,"time":1783082852408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":" world"}}} +{"type":"assistant/chunk","seq":55,"time":1783082852408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783082852437,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":57,"time":1783082852471,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":58,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} +{"type":"assistant/chunk","seq":59,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":60,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1783082852474,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with exactly \"DONE\"."},{"type":"tool-call","id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"tool/call","seq":62,"time":1783082852474,"data":{"turn":1,"step":1,"callId":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} +{"type":"tool/result","seq":63,"time":1783082852481,"data":{"turn":1,"step":1,"callId":"call_00_4aj3gzzSDsP64mCcrn8k4591","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-bol9fl/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1783082852481,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1783082852482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1783082852837,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":67,"time":1783082852838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":68,"time":1783082852954,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":69,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":71,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":72,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":73,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":74,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":76,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":77,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":81,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":82,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":83,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":84,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":85,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":86,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":87,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":88,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":89,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":90,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":21,"cacheReadTokens":2304,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":91,"time":1783082853079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":92,"time":1783082853079,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":126,"outputTokens":21,"cacheReadTokens":2304,"reasoningTokens":18}},"sourceEventSeqs":[66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91],"surfaceOp":"append"} +{"type":"step/end","seq":93,"time":1783082853079,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":94,"time":1783082853079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl new file mode 100644 index 0000000000..ba9d2e691f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -0,0 +1,56 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" named"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" notes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" world"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_4aj3gzzSDsP64mCcrn8k4591","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_4aj3gzzSDsP64mCcrn8k4591","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}],"title":"Write notes.txt"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl new file mode 100644 index 0000000000..a9bcac1b03 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -0,0 +1,400 @@ +{"type":"session","version":0,"id":"5d77f7c5-7470-49f8-8c22-cd61d318b994","createdAt":1783095158367,"cwd":"/tmp/acp-snap-cwd-q62GvW"} +{"type":"turn/start","seq":0,"time":1783095158371,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095158372,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095158373,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095159304,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095159305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095159457,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095159480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095159480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095159480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095159481,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095159481,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783095159481,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":12,"time":1783095159504,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":13,"time":1783095159526,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":14,"time":1783095159527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":15,"time":1783095159527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":16,"time":1783095159527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":17,"time":1783095159527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":18,"time":1783095159527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":19,"time":1783095159549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":20,"time":1783095159571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":21,"time":1783095159572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":22,"time":1783095159572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":23,"time":1783095159572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":24,"time":1783095159572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783095159572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":26,"time":1783095159594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":27,"time":1783095159594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":28,"time":1783095159594,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783095159669,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1783095159669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":31,"time":1783095159670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":32,"time":1783095159670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095159685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":34,"time":1783095159686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095159686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":36,"time":1783095159686,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783095159711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":38,"time":1783095159711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":39,"time":1783095159711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":40,"time":1783095159711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":41,"time":1783095159711,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095159758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":43,"time":1783095159758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095159758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":45,"time":1783095159758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095159758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":47,"time":1783095159780,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783095159781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":49,"time":1783095159781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":50,"time":1783095159781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":51,"time":1783095159781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":52,"time":1783095159781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":53,"time":1783095159803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783095159803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":55,"time":1783095159850,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the command `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":56,"time":1783095159850,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1783095159850,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":91,"cacheReadTokens":1664,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":58,"time":1783095159850,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1783095159852,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":91,"cacheReadTokens":1664,"reasoningTokens":25}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1783095159852,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":61,"time":1783095159867,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":62,"time":1783095159875,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.870597999999973}} +{"type":"tool/result","seq":63,"time":1783095159875,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1783095159875,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1783095159876,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1783095161067,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":67,"time":1783095161067,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":68,"time":1783095161145,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":69,"time":1783095161167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":70,"time":1783095161168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":71,"time":1783095161168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":72,"time":1783095161168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":73,"time":1783095161189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":74,"time":1783095161190,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":75,"time":1783095161191,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":76,"time":1783095161191,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":77,"time":1783095161191,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1783095161191,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rer"}}} +{"type":"assistant/chunk","seq":79,"time":1783095161212,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"un"}}} +{"type":"assistant/chunk","seq":80,"time":1783095161213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":81,"time":1783095161213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":82,"time":1783095161213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":83,"time":1783095161234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/s"}}} +{"type":"assistant/chunk","seq":84,"time":1783095161258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"um"}}} +{"type":"assistant/chunk","seq":85,"time":1783095161258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mary"}}} +{"type":"assistant/chunk","seq":86,"time":1783095161258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":87,"time":1783095161258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":88,"time":1783095161259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1783095161259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":90,"time":1783095161282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" again"}}} +{"type":"assistant/chunk","seq":91,"time":1783095161282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":92,"time":1783095161304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":93,"time":1783095161304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":94,"time":1783095161304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" included"}}} +{"type":"assistant/chunk","seq":95,"time":1783095161327,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":96,"time":1783095161328,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":97,"time":1783095161328,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":98,"time":1783095161328,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":99,"time":1783095161328,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":100,"time":1783095161328,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" check"}}} +{"type":"assistant/chunk","seq":101,"time":1783095161350,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":102,"time":1783095161351,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" maybe"}}} +{"type":"assistant/chunk","seq":103,"time":1783095161373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":104,"time":1783095161374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":105,"time":1783095161374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" needs"}}} +{"type":"assistant/chunk","seq":106,"time":1783095161374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":107,"time":1783095161398,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":108,"time":1783095161399,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":109,"time":1783095161399,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" descriptive"}}} +{"type":"assistant/chunk","seq":110,"time":1783095161421,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":111,"time":1783095161442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":112,"time":1783095161442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":113,"time":1783095161443,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} +{"type":"assistant/chunk","seq":114,"time":1783095161443,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":115,"time":1783095161443,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" another"}}} +{"type":"assistant/chunk","seq":116,"time":1783095161466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" issue"}}} +{"type":"assistant/chunk","seq":117,"time":1783095161466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":118,"time":1783095161466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":119,"time":1783095161466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":120,"time":1783095161467,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":121,"time":1783095161467,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" again"}}} +{"type":"assistant/chunk","seq":122,"time":1783095161488,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":123,"time":1783095161488,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":124,"time":1783095161489,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" slightly"}}} +{"type":"assistant/chunk","seq":125,"time":1783095161518,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":126,"time":1783095161519,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} +{"type":"assistant/chunk","seq":127,"time":1783095161534,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":128,"time":1783095161557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":129,"time":1783095161558,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":130,"time":1783095161581,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happens"}}} +{"type":"assistant/chunk","seq":131,"time":1783095161581,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":132,"time":1783095161625,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":133,"time":1783095161626,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":134,"time":1783095161651,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":135,"time":1783095161652,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":136,"time":1783095161652,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":137,"time":1783095161671,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1783095161672,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":139,"time":1783095161672,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1783095161672,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":141,"time":1783095161694,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":142,"time":1783095161695,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":143,"time":1783095161695,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":144,"time":1783095161695,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":145,"time":1783095161717,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":146,"time":1783095161718,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":1783095161739,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":148,"time":1783095161740,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":149,"time":1783095161740,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":150,"time":1783095161740,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":151,"time":1783095161762,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":152,"time":1783095161762,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":153,"time":1783095161785,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":154,"time":1783095161807,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":" string"}}} +{"type":"assistant/chunk","seq":155,"time":1783095161808,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":156,"time":1783095161808,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":157,"time":1783095161808,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":158,"time":1783095161808,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1783095161832,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":160,"time":1783095161878,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by policy. I need to rerun with a description/summary. Let me try again - I already included a description. Let me check if maybe the description needs to be more descriptive, or if there's another issue.\n\nLet me try again with a slightly different approach to see what happens."}}}} +{"type":"assistant/chunk","seq":161,"time":1783095161879,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}}}} +{"type":"assistant/chunk","seq":162,"time":1783095161879,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":187,"outputTokens":133,"cacheReadTokens":1664,"reasoningTokens":65}}}} +{"type":"assistant/chunk","seq":163,"time":1783095161879,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":164,"time":1783095161879,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by policy. I need to rerun with a description/summary. Let me try again - I already included a description. Let me check if maybe the description needs to be more descriptive, or if there's another issue.\n\nLet me try again with a slightly different approach to see what happens."},{"type":"tool-call","id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}],"usage":{"inputTokens":187,"outputTokens":133,"cacheReadTokens":1664,"reasoningTokens":65}},"sourceEventSeqs":[66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163],"surfaceOp":"append"} +{"type":"tool/call","seq":165,"time":1783095161879,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}} +{"type":"hook/invoked","seq":166,"time":1783095161893,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} +{"type":"hook/result","seq":167,"time":1783095161902,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.773494000000028}} +{"type":"tool/result","seq":168,"time":1783095161902,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[165],"surfaceOp":"append"} +{"type":"step/end","seq":169,"time":1783095161903,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":170,"time":1783095161903,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":171,"time":1783095162978,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":172,"time":1783095162978,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"It"}}} +{"type":"assistant/chunk","seq":173,"time":1783095163048,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} +{"type":"assistant/chunk","seq":174,"time":1783095163069,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":175,"time":1783095163070,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":176,"time":1783095163070,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":177,"time":1783095163070,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":178,"time":1783095163094,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":179,"time":1783095163095,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":180,"time":1783095163095,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":181,"time":1783095163115,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":182,"time":1783095163115,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" repeatedly"}}} +{"type":"assistant/chunk","seq":183,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":184,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":185,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":186,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":187,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":188,"time":1783095163137,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":189,"time":1783095163159,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":190,"time":1783095163159,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":191,"time":1783095163182,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} +{"type":"assistant/chunk","seq":192,"time":1783095163182,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":193,"time":1783095163205,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" perhaps"}}} +{"type":"assistant/chunk","seq":194,"time":1783095163206,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":195,"time":1783095163206,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" issue"}}} +{"type":"assistant/chunk","seq":196,"time":1783095163206,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":197,"time":1783095163206,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" unrelated"}}} +{"type":"assistant/chunk","seq":198,"time":1783095163228,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":199,"time":1783095163228,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} +{"type":"assistant/chunk","seq":200,"time":1783095163251,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":201,"time":1783095163252,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":202,"time":1783095163252,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":203,"time":1783095163252,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":204,"time":1783095163252,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":205,"time":1783095163277,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":206,"time":1783095163277,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":207,"time":1783095163298,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" longer"}}} +{"type":"assistant/chunk","seq":208,"time":1783095163319,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":209,"time":1783095163342,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":210,"time":1783095163342,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":211,"time":1783095163365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":212,"time":1783095163365,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" format"}}} +{"type":"assistant/chunk","seq":213,"time":1783095163388,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":214,"time":1783095163388,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} +{"type":"assistant/chunk","seq":215,"time":1783095163388,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":216,"time":1783095163388,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":217,"time":1783095163388,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" think"}}} +{"type":"assistant/chunk","seq":218,"time":1783095163389,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":219,"time":1783095163411,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":220,"time":1783095163411,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" rejection"}}} +{"type":"assistant/chunk","seq":221,"time":1783095163433,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" might"}}} +{"type":"assistant/chunk","seq":222,"time":1783095163434,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":223,"time":1783095163434,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":224,"time":1783095163434,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":225,"time":1783095163434,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} +{"type":"assistant/chunk","seq":226,"time":1783095163456,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"/output"}}} +{"type":"assistant/chunk","seq":227,"time":1783095163478,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":228,"time":1783095163479,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" shown"}}} +{"type":"assistant/chunk","seq":229,"time":1783095163504,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":230,"time":1783095163504,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":231,"time":1783095163504,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":232,"time":1783095163504,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":233,"time":1783095163504,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" running"}}} +{"type":"assistant/chunk","seq":234,"time":1783095163524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":235,"time":1783095163524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":236,"time":1783095163547,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":237,"time":1783095163569,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":238,"time":1783095163592,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":239,"time":1783095163593,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":240,"time":1783095163593,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" know"}}} +{"type":"assistant/chunk","seq":241,"time":1783095163616,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":242,"time":1783095163617,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":243,"time":1783095163617,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":244,"time":1783095163617,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":245,"time":1783095163640,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":246,"time":1783095163664,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":247,"time":1783095163664,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":248,"time":1783095163664,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":249,"time":1783095163729,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":250,"time":1783095163730,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":251,"time":1783095163730,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":252,"time":1783095163730,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":253,"time":1783095163752,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":254,"time":1783095163753,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":255,"time":1783095163753,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":256,"time":1783095163753,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":257,"time":1783095163776,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":258,"time":1783095163776,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":259,"time":1783095163776,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":260,"time":1783095163776,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":261,"time":1783095163776,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":262,"time":1783095163822,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":263,"time":1783095163822,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":264,"time":1783095163822,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":265,"time":1783095163822,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":266,"time":1783095163822,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":267,"time":1783095163853,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":268,"time":1783095163853,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"Print"}}} +{"type":"assistant/chunk","seq":269,"time":1783095163864,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":270,"time":1783095163864,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":271,"time":1783095163864,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":272,"time":1783095163888,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":273,"time":1783095163888,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":274,"time":1783095163888,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":275,"time":1783095163908,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":276,"time":1783095163934,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"It seems the tool output is being rejected by policy repeatedly. Let me try a different command or approach - perhaps the issue is unrelated to my command. Let me try with a longer description or a different format.\n\nActually, I think the policy rejection might be about the content/output being shown. Let me try running it and just report what I know.\n\nLet me try one more time."}}}} +{"type":"assistant/chunk","seq":277,"time":1783095163934,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":278,"time":1783095163934,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":216,"outputTokens":144,"cacheReadTokens":1792,"reasoningTokens":77}}}} +{"type":"assistant/chunk","seq":279,"time":1783095163934,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":280,"time":1783095163935,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"It seems the tool output is being rejected by policy repeatedly. Let me try a different command or approach - perhaps the issue is unrelated to my command. Let me try with a longer description or a different format.\n\nActually, I think the policy rejection might be about the content/output being shown. Let me try running it and just report what I know.\n\nLet me try one more time."},{"type":"tool-call","id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":216,"outputTokens":144,"cacheReadTokens":1792,"reasoningTokens":77}},"sourceEventSeqs":[171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279],"surfaceOp":"append"} +{"type":"tool/call","seq":281,"time":1783095163935,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":282,"time":1783095163944,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:3","matcher":"bash"}} +{"type":"hook/result","seq":283,"time":1783095163951,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.553152000000409}} +{"type":"tool/result","seq":284,"time":1783095163951,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[281],"surfaceOp":"append"} +{"type":"step/end","seq":285,"time":1783095163951,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":286,"time":1783095163951,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":287,"time":1783095165207,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":288,"time":1783095165207,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":289,"time":1783095165358,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":290,"time":1783095165380,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} +{"type":"assistant/chunk","seq":291,"time":1783095165402,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" getting"}}} +{"type":"assistant/chunk","seq":292,"time":1783095165426,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":293,"time":1783095165426,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":294,"time":1783095165449,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":295,"time":1783095165449,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":296,"time":1783095165449,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} +{"type":"assistant/chunk","seq":297,"time":1783095165470,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" appears"}}} +{"type":"assistant/chunk","seq":298,"time":1783095165470,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":299,"time":1783095165470,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":300,"time":1783095165471,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":301,"time":1783095165492,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" system"}}} +{"type":"assistant/chunk","seq":302,"time":1783095165492,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} +{"type":"assistant/chunk","seq":303,"time":1783095165492,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":304,"time":1783095165516,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" restriction"}}} +{"type":"assistant/chunk","seq":305,"time":1783095165538,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":306,"time":1783095165539,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} +{"type":"assistant/chunk","seq":307,"time":1783095165539,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" end"}}} +{"type":"assistant/chunk","seq":308,"time":1783095165560,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":309,"time":1783095165560,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":310,"time":1783095165580,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" cannot"}}} +{"type":"assistant/chunk","seq":311,"time":1783095165581,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" execute"}}} +{"type":"assistant/chunk","seq":312,"time":1783095165581,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":313,"time":1783095165602,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":314,"time":1783095165603,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" due"}}} +{"type":"assistant/chunk","seq":315,"time":1783095165625,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":316,"time":1783095165625,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":317,"time":1783095165647,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":318,"time":1783095165647,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":319,"time":1783095165648,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":320,"time":1783095165648,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":321,"time":1783095165670,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":322,"time":1783095165692,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":323,"time":1783095165692,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":324,"time":1783095165692,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":325,"time":1783095165714,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":326,"time":1783095165714,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":327,"time":1783095165715,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":328,"time":1783095165715,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":329,"time":1783095165715,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":330,"time":1783095165738,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":331,"time":1783095165760,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":332,"time":1783095165782,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" received"}}} +{"type":"assistant/chunk","seq":333,"time":1783095165783,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" ("}}} +{"type":"assistant/chunk","seq":334,"time":1783095165803,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"three"}}} +{"type":"assistant/chunk","seq":335,"time":1783095165824,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" consecutive"}}} +{"type":"assistant/chunk","seq":336,"time":1783095165845,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" attempts"}}} +{"type":"assistant/chunk","seq":337,"time":1783095165868,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":")"}}} +{"type":"assistant/chunk","seq":338,"time":1783095165890,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":339,"time":1783095165891,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":340,"time":1783095165891,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"**"}}} +{"type":"assistant/chunk","seq":341,"time":1783095165911,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":342,"time":1783095165912,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":343,"time":1783095165912,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":344,"time":1783095165912,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":345,"time":1783095165912,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":346,"time":1783095165912,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":347,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":348,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" rer"}}} +{"type":"assistant/chunk","seq":349,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"un"}}} +{"type":"assistant/chunk","seq":350,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" with"}}} +{"type":"assistant/chunk","seq":351,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" a"}}} +{"type":"assistant/chunk","seq":352,"time":1783095165933,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" summary"}}} +{"type":"assistant/chunk","seq":353,"time":1783095165955,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} +{"type":"assistant/chunk","seq":354,"time":1783095165955,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":355,"time":1783095165956,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"**\n\n"}}} +{"type":"assistant/chunk","seq":356,"time":1783095165956,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"It"}}} +{"type":"assistant/chunk","seq":357,"time":1783095165956,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" appears"}}} +{"type":"assistant/chunk","seq":358,"time":1783095165978,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" a"}}} +{"type":"assistant/chunk","seq":359,"time":1783095166000,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" system"}}} +{"type":"assistant/chunk","seq":360,"time":1783095166023,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":361,"time":1783095166046,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":362,"time":1783095166047,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" blocking"}}} +{"type":"assistant/chunk","seq":363,"time":1783095166047,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":364,"time":1783095166066,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" execution"}}} +{"type":"assistant/chunk","seq":365,"time":1783095166067,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" of"}}} +{"type":"assistant/chunk","seq":366,"time":1783095166067,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":367,"time":1783095166067,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} +{"type":"assistant/chunk","seq":368,"time":1783095166089,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" HE"}}} +{"type":"assistant/chunk","seq":369,"time":1783095166089,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":370,"time":1783095166090,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":371,"time":1783095166090,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":372,"time":1783095166090,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" ("}}} +{"type":"assistant/chunk","seq":373,"time":1783095166111,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"or"}}} +{"type":"assistant/chunk","seq":374,"time":1783095166112,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" any"}}} +{"type":"assistant/chunk","seq":375,"time":1783095166136,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":376,"time":1783095166157,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":")"}}} +{"type":"assistant/chunk","seq":377,"time":1783095166179,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" via"}}} +{"type":"assistant/chunk","seq":378,"time":1783095166180,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":379,"time":1783095166205,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":380,"time":1783095166206,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":381,"time":1783095166206,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":382,"time":1783095166226,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":383,"time":1783095166227,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" context"}}} +{"type":"assistant/chunk","seq":384,"time":1783095166248,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":385,"time":1783095166249,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":386,"time":1783095166249,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" am"}}} +{"type":"assistant/chunk","seq":387,"time":1783095166271,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" unable"}}} +{"type":"assistant/chunk","seq":388,"time":1783095166271,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":389,"time":1783095166271,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" run"}}} +{"type":"assistant/chunk","seq":390,"time":1783095166272,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":" it"}}} +{"type":"assistant/chunk","seq":391,"time":1783095166300,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":392,"time":1783095166300,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool keeps getting rejected by policy. This appears to be a system-level policy restriction on my end. I cannot execute this command due to the policy. Let me report this to the user."}}}} +{"type":"assistant/chunk","seq":393,"time":1783095166301,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I received (three consecutive attempts) was:\n\n**`tool output rejected by policy: rerun with a summary instead`**\n\nIt appears a system policy is blocking the execution of `echo HELLO` (or any command) via the bash tool in this context. I am unable to run it."}}}} +{"type":"assistant/chunk","seq":394,"time":1783095166301,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":256,"outputTokens":104,"cacheReadTokens":1920,"reasoningTokens":39}}}} +{"type":"assistant/chunk","seq":395,"time":1783095166301,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":396,"time":1783095166301,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The tool keeps getting rejected by policy. This appears to be a system-level policy restriction on my end. I cannot execute this command due to the policy. Let me report this to the user."},{"type":"text","text":"The tool result I received (three consecutive attempts) was:\n\n**`tool output rejected by policy: rerun with a summary instead`**\n\nIt appears a system policy is blocking the execution of `echo HELLO` (or any command) via the bash tool in this context. I am unable to run it."}],"usage":{"inputTokens":256,"outputTokens":104,"cacheReadTokens":1920,"reasoningTokens":39}},"sourceEventSeqs":[287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395],"surfaceOp":"append"} +{"type":"step/end","seq":397,"time":1783095166301,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":398,"time":1783095166301,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl new file mode 100644 index 0000000000..ef5692d574 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -0,0 +1,279 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_e9zAlNQhIVFKzoStUuWI7161","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_e9zAlNQhIVFKzoStUuWI7161","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"un"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"um"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mary"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" again"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" included"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" check"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" maybe"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" descriptive"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" another"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" issue"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" again"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" slightly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happens"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SFRulyHdhTsyfllc6Gro3716","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo the string HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_SFRulyHdhTsyfllc6Gro3716","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"It"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" repeatedly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" perhaps"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" issue"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" unrelated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" my"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" longer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" format"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" think"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejection"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" might"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" shown"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" running"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" know"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_9CQs2NzrhnwYjdvmjsR10424","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9CQs2NzrhnwYjdvmjsR10424","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" getting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" appears"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-level"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" restriction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" my"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" end"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" execute"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" due"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" received"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"three"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" consecutive"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" attempts"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"**"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"un"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summary"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"**\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"It"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" appears"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" execution"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" via"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" context"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" am"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" unable"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json new file mode 100644 index 0000000000..f5c4fe5f3b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo 'tool output rejected by policy: rerun with a summary instead' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/input.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl new file mode 100644 index 0000000000..053ce1c251 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -0,0 +1,117 @@ +{"type":"session","version":0,"id":"ea829234-968c-4b02-b5f0-211c63c5e20b","createdAt":1783095111649,"cwd":"/tmp/acp-snap-cwd-XZy8Bu"} +{"type":"turn/start","seq":0,"time":1783095111653,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095111654,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095111655,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095112601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095112601,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095112759,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095112782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095112782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095112782,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095112783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095112783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":11,"time":1783095112806,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":12,"time":1783095112807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":13,"time":1783095112807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":14,"time":1783095112807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":15,"time":1783095112807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":16,"time":1783095112807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1783095112831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095112848,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":19,"time":1783095112849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1783095112849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783095112849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":22,"time":1783095112849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783095112849,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":24,"time":1783095112871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":25,"time":1783095112871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":26,"time":1783095112871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095112938,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1783095112939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":29,"time":1783095112939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":30,"time":1783095112940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783095112966,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":32,"time":1783095112967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095112967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":34,"time":1783095112967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095112983,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783095112984,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":37,"time":1783095112984,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":38,"time":1783095112984,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":39,"time":1783095112984,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095113029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1783095113029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095113030,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1783095113030,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095113030,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783095113052,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095113053,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":47,"time":1783095113074,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":48,"time":1783095113075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":49,"time":1783095113075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":50,"time":1783095113075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":51,"time":1783095113075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783095113097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1783095113148,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783095113148,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1783095113148,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":56,"time":1783095113148,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1783095113150,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1783095113150,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1783095113166,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1783095113175,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":8.75206100000014}} +{"type":"tool/result","seq":61,"time":1783095113175,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"context/message","seq":62,"time":1783095113176,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1783095113176,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":64,"time":1783095113176,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":65,"time":1783095113867,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":66,"time":1783095113867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":67,"time":1783095113987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":68,"time":1783095114010,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":69,"time":1783095114010,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":70,"time":1783095114011,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":71,"time":1783095114011,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":72,"time":1783095114011,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":73,"time":1783095114033,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":74,"time":1783095114034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":75,"time":1783095114034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":76,"time":1783095114034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":77,"time":1783095114034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":78,"time":1783095114034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":79,"time":1783095114057,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":80,"time":1783095114057,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1783095114057,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":82,"time":1783095114058,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":83,"time":1783095114058,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":84,"time":1783095114058,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1783095114086,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":86,"time":1783095114086,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":87,"time":1783095114086,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":88,"time":1783095114105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":89,"time":1783095114105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} +{"type":"assistant/chunk","seq":90,"time":1783095114106,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":91,"time":1783095114106,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":92,"time":1783095114106,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":93,"time":1783095114129,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":94,"time":1783095114130,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":95,"time":1783095114130,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":96,"time":1783095114130,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":97,"time":1783095114153,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":98,"time":1783095114153,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":99,"time":1783095114154,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":100,"time":1783095114154,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":101,"time":1783095114176,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" returned"}}} +{"type":"assistant/chunk","seq":102,"time":1783095114177,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":103,"time":1783095114204,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":104,"time":1783095114204,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} +{"type":"assistant/chunk","seq":105,"time":1783095114204,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":106,"time":1783095114204,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":107,"time":1783095114204,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":108,"time":1783095114205,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":109,"time":1783095114205,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to run `echo HELLO` and report the result verbatim. The output was \"HELLO\". Let me report that."}}}} +{"type":"assistant/chunk","seq":110,"time":1783095114205,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":111,"time":1783095114205,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":70,"outputTokens":43,"cacheReadTokens":1792,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":112,"time":1783095114205,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":113,"time":1783095114205,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to run `echo HELLO` and report the result verbatim. The output was \"HELLO\". Let me report that."},{"type":"text","text":"The tool returned:\n\n```\nHELLO\n```"}],"usage":{"inputTokens":70,"outputTokens":43,"cacheReadTokens":1792,"reasoningTokens":32}},"sourceEventSeqs":[65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1783095114206,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":115,"time":1783095114206,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl new file mode 100644 index 0000000000..2497acffa0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -0,0 +1,70 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_upvgqMKJ4hJck9LxQn0p5500","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_upvgqMKJ4hJck9LxQn0p5500","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nHELLO\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/workspace/hooks.json new file mode 100644 index 0000000000..ef832fc97a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/workspace/hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"Note: command output has been verified against the audit log.\"}}'" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/input.json b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl new file mode 100644 index 0000000000..b0e7a5f00f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -0,0 +1,128 @@ +{"type":"session","version":0,"id":"f2dbf2b3-16a5-43ec-a31f-3113555b3f11","createdAt":1783095042592,"cwd":"/tmp/acp-snap-cwd-DBcoGT"} +{"type":"turn/start","seq":0,"time":1783095042596,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095042596,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095042597,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095043262,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095043262,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095043395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095043418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095043418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095043418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095043418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095043419,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":11,"time":1783095043419,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":12,"time":1783095043440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":13,"time":1783095043440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":14,"time":1783095043440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":15,"time":1783095043440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":16,"time":1783095043440,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1783095043464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":19,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":22,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783095043485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":24,"time":1783095043507,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":25,"time":1783095043508,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":26,"time":1783095043508,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095043575,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1783095043575,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":29,"time":1783095043576,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":30,"time":1783095043576,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783095043598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":32,"time":1783095043598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095043598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":34,"time":1783095043598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095043620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783095043621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":37,"time":1783095043621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":38,"time":1783095043621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":39,"time":1783095043621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095043668,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1783095043668,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095043668,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1783095043668,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095043668,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783095043693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095043693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":47,"time":1783095043693,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":48,"time":1783095043718,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":49,"time":1783095043718,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":50,"time":1783095043718,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":51,"time":1783095043718,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783095043736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1783095043783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783095043783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1783095043783,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":56,"time":1783095043784,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1783095043785,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1783095043786,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1783095043786,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1783095043800,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":12.982377999999699}} +{"type":"tool/result","seq":61,"time":1783095043800,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","content":[{"type":"text","text":"Error: bash requires manual approval in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783095043800,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1783095043801,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783095044547,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1783095044547,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1783095044693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":67,"time":1783095044728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":68,"time":1783095044728,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":69,"time":1783095044751,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":70,"time":1783095044751,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":71,"time":1783095044751,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} +{"type":"assistant/chunk","seq":72,"time":1783095044751,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":73,"time":1783095044773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":74,"time":1783095044773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" manual"}}} +{"type":"assistant/chunk","seq":75,"time":1783095044773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" approval"}}} +{"type":"assistant/chunk","seq":76,"time":1783095044773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":77,"time":1783095044796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":78,"time":1783095044796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":79,"time":1783095044796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":80,"time":1783095044796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":81,"time":1783095044820,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":82,"time":1783095044821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":83,"time":1783095044821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":84,"time":1783095044821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":85,"time":1783095044821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":86,"time":1783095044821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":87,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":88,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":89,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":90,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":91,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":92,"time":1783095044843,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":93,"time":1783095044865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":94,"time":1783095044865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":95,"time":1783095044865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":96,"time":1783095044865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":97,"time":1783095044887,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":98,"time":1783095044887,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":99,"time":1783095044888,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":100,"time":1783095044888,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":101,"time":1783095044911,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":102,"time":1783095044933,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} +{"type":"assistant/chunk","seq":103,"time":1783095044956,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} +{"type":"assistant/chunk","seq":104,"time":1783095044957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":105,"time":1783095044957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":106,"time":1783095044957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":107,"time":1783095044980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":108,"time":1783095044980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":109,"time":1783095044980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":110,"time":1783095044980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":111,"time":1783095044980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":112,"time":1783095045002,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":113,"time":1783095045002,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" manual"}}} +{"type":"assistant/chunk","seq":114,"time":1783095045002,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" approval"}}} +{"type":"assistant/chunk","seq":115,"time":1783095045002,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":116,"time":1783095045003,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":117,"time":1783095045003,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":118,"time":1783095045025,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":119,"time":1783095045025,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":120,"time":1783095045050,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. The user asked me to report the tool result verbatim. Let me do that."}}}} +{"type":"assistant/chunk","seq":121,"time":1783095045050,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} +{"type":"assistant/chunk","seq":122,"time":1783095045050,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":54,"outputTokens":55,"cacheReadTokens":1792,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":123,"time":1783095045050,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":124,"time":1783095045051,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool returned an error saying it requires manual approval in this session. The user asked me to report the tool result verbatim. Let me do that."},{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}],"usage":{"inputTokens":54,"outputTokens":55,"cacheReadTokens":1792,"reasoningTokens":32}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123],"surfaceOp":"append"} +{"type":"step/end","seq":125,"time":1783095045051,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":126,"time":1783095045051,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl new file mode 100644 index 0000000000..60aaee5aa5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -0,0 +1,82 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: bash requires manual approval in this session\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" an"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" saying"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" manual"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approval"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" got"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" back"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" manual"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" approval"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/workspace/hooks.json new file mode 100644 index 0000000000..ea99b0c36a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/workspace/hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"bash requires manual approval in this session\"}}'" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/input.json b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl new file mode 100644 index 0000000000..501f3a8594 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -0,0 +1,136 @@ +{"type":"session","version":0,"id":"b2e43d8a-1500-4804-963c-ba5330da3d49","createdAt":1783095008558,"cwd":"/tmp/acp-snap-cwd-b8ZwkY"} +{"type":"turn/start","seq":0,"time":1783095008562,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095008563,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095008563,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095009265,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095009266,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095009481,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095009482,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095009482,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095009482,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095009483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095009483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":11,"time":1783095009506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":12,"time":1783095009506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":13,"time":1783095009506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":14,"time":1783095009506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":15,"time":1783095009506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":16,"time":1783095009507,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1783095009527,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095009554,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":19,"time":1783095009555,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1783095009555,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783095009555,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":22,"time":1783095009555,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783095009555,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":24,"time":1783095009576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":25,"time":1783095009576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":26,"time":1783095009576,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095009644,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1783095009644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":29,"time":1783095009645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":30,"time":1783095009645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783095009667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":32,"time":1783095009667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095009667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":34,"time":1783095009667,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095009689,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783095009690,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":37,"time":1783095009690,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":38,"time":1783095009690,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":39,"time":1783095009690,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095009736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1783095009736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095009736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1783095009736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095009736,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783095009758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095009758,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":47,"time":1783095009781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":48,"time":1783095009781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":49,"time":1783095009781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":50,"time":1783095009781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":51,"time":1783095009781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783095009804,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1783095009897,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783095009897,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1783095009897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1736,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":56,"time":1783095009897,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1783095009899,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":1736,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1783095009899,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1783095009900,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1783095009915,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":14.778092000000015}} +{"type":"tool/result","seq":61,"time":1783095009915,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783095009916,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1783095009916,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783095011013,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1783095011014,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1783095011133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":67,"time":1783095011160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":68,"time":1783095011160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":69,"time":1783095011160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" disabled"}}} +{"type":"assistant/chunk","seq":70,"time":1783095011161,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":71,"time":1783095011161,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":72,"time":1783095011161,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":73,"time":1783095011184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":74,"time":1783095011185,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":75,"time":1783095011185,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1783095011185,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":77,"time":1783095011204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":78,"time":1783095011204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":79,"time":1783095011204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":80,"time":1783095011204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":81,"time":1783095011225,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":82,"time":1783095011226,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":83,"time":1783095011226,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":84,"time":1783095011226,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":85,"time":1783095011226,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":86,"time":1783095011226,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":87,"time":1783095011248,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1783095011248,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1783095011248,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":90,"time":1783095011248,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":91,"time":1783095011271,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" returned"}}} +{"type":"assistant/chunk","seq":92,"time":1783095011296,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":93,"time":1783095011297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":94,"time":1783095011297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":95,"time":1783095011297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":96,"time":1783095011297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":97,"time":1783095011328,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":98,"time":1783095011329,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" disabled"}}} +{"type":"assistant/chunk","seq":99,"time":1783095011329,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":100,"time":1783095011329,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":101,"time":1783095011329,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":102,"time":1783095011329,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":103,"time":1783095011339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":104,"time":1783095011340,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":105,"time":1783095011340,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n\n"}}} +{"type":"assistant/chunk","seq":106,"time":1783095011340,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"That"}}} +{"type":"assistant/chunk","seq":107,"time":1783095011362,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":108,"time":1783095011363,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":109,"time":1783095011363,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":110,"time":1783095011385,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":111,"time":1783095011386,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":112,"time":1783095011386,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":113,"time":1783095011386,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":114,"time":1783095011408,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":115,"time":1783095011409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" proceed"}}} +{"type":"assistant/chunk","seq":116,"time":1783095011409,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" further"}}} +{"type":"assistant/chunk","seq":117,"time":1783095011431,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" because"}}} +{"type":"assistant/chunk","seq":118,"time":1783095011454,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":119,"time":1783095011455,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":120,"time":1783095011455,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":121,"time":1783095011455,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":122,"time":1783095011455,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" not"}}} +{"type":"assistant/chunk","seq":123,"time":1783095011477,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" available"}}} +{"type":"assistant/chunk","seq":124,"time":1783095011477,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":125,"time":1783095011477,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":126,"time":1783095011477,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":127,"time":1783095011500,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":128,"time":1783095011501,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."}}}} +{"type":"assistant/chunk","seq":129,"time":1783095011501,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n```\nError: bash is disabled by policy in this session\n```\n\nThat is the verbatim result. I cannot proceed further because the bash tool is not available in this session."}}}} +{"type":"assistant/chunk","seq":130,"time":1783095011501,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":183,"outputTokens":63,"cacheReadTokens":1664,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":131,"time":1783095011501,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":132,"time":1783095011501,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."},{"type":"text","text":"The tool returned:\n\n```\nError: bash is disabled by policy in this session\n```\n\nThat is the verbatim result. I cannot proceed further because the bash tool is not available in this session."}],"usage":{"inputTokens":183,"outputTokens":63,"cacheReadTokens":1664,"reasoningTokens":23}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131],"surfaceOp":"append"} +{"type":"step/end","seq":133,"time":1783095011501,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":134,"time":1783095011502,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl new file mode 100644 index 0000000000..847d79a85c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -0,0 +1,90 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_NQfQgkyjofpjsaiEUcsX0103","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_NQfQgkyjofpjsaiEUcsX0103","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: bash is disabled by policy in this session\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"That"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" proceed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" further"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" available"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/workspace/hooks.json new file mode 100644 index 0000000000..f509c811c3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/workspace/hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo 'bash is disabled by policy in this session' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/input.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/input.json new file mode 100644 index 0000000000..1995199566 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Delete everything in the repo." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl new file mode 100644 index 0000000000..b5f81fdaea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook","durationMs":0}} +{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by policy hook"}} +{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl new file mode 100644 index 0000000000..6f6e5b662f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json new file mode 100644 index 0000000000..ee3da88fb1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/input.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/input.json new file mode 100644 index 0000000000..348d8960d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "What is my favorite color? Reply with just the color and stop. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl new file mode 100644 index 0000000000..33cf2c8ba0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -0,0 +1,60 @@ +{"type":"session","version":0,"id":"b0c9d2c7-f95b-4750-be8a-10121253b006","createdAt":1783095036603,"cwd":"/tmp/acp-snap-cwd-LW2rSZ"} +{"type":"turn/start","seq":0,"time":1783095036609,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"hook/invoked","seq":1,"time":1783095036610,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} +{"type":"hook/result","seq":2,"time":1783095036623,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":11.874454999999898}} +{"type":"user/message","seq":3,"time":1783095036623,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"context/message","seq":4,"time":1783095036623,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1783095036624,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":6,"time":1783095037385,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1783095037385,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1783095037558,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1783095037617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":10,"time":1783095037645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":11,"time":1783095037646,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":12,"time":1783095037669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" their"}}} +{"type":"assistant/chunk","seq":13,"time":1783095037669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" favorite"}}} +{"type":"assistant/chunk","seq":14,"time":1783095037669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" color"}}} +{"type":"assistant/chunk","seq":15,"time":1783095037669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":16,"time":1783095037691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":17,"time":1783095037692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095037692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} +{"type":"assistant/chunk","seq":19,"time":1783095037692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":20,"time":1783095037692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":21,"time":1783095037714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":22,"time":1783095037714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" indicates"}}} +{"type":"assistant/chunk","seq":23,"time":1783095037736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":24,"time":1783095037737,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" previously"}}} +{"type":"assistant/chunk","seq":25,"time":1783095037760,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stated"}}} +{"type":"assistant/chunk","seq":26,"time":1783095037760,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":27,"time":1783095037783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":28,"time":1783095037783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" te"}}} +{"type":"assistant/chunk","seq":29,"time":1783095037783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"al"}}} +{"type":"assistant/chunk","seq":30,"time":1783095037783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783095037783,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":32,"time":1783095037807,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":33,"time":1783095037830,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":34,"time":1783095037854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":35,"time":1783095037854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":36,"time":1783095037876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":37,"time":1783095037876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":38,"time":1783095037899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":39,"time":1783095037899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":40,"time":1783095037899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" color"}}} +{"type":"assistant/chunk","seq":41,"time":1783095037899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":42,"time":1783095037899,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":43,"time":1783095037900,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":44,"time":1783095037923,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":45,"time":1783095037923,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":46,"time":1783095037923,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":47,"time":1783095037923,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":48,"time":1783095037923,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":49,"time":1783095037945,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":50,"time":1783095037945,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":51,"time":1783095037946,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":52,"time":1783095037946,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about their favorite color, and the context from a plugin indicates they previously stated it's teal. They explicitly asked me to reply with just the color and stop, without using any tools."}}}} +{"type":"assistant/chunk","seq":53,"time":1783095037946,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":54,"time":1783095037946,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":86,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":42}}}} +{"type":"assistant/chunk","seq":55,"time":1783095037946,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":56,"time":1783095037948,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking about their favorite color, and the context from a plugin indicates they previously stated it's teal. They explicitly asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"usage":{"inputTokens":86,"outputTokens":45,"cacheReadTokens":1664,"reasoningTokens":42}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55],"surfaceOp":"append"} +{"type":"step/end","seq":57,"time":1783095037948,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":58,"time":1783095037948,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl new file mode 100644 index 0000000000..2ca7284ede --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -0,0 +1,47 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" their"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" favorite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" color"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" context"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" indicates"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" they"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" previously"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" te"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"al"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" They"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicitly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" color"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"te"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"al"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/workspace/hooks.json new file mode 100644 index 0000000000..0856516f73 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/workspace/hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"The user has previously stated their favorite color is teal.\"}}'" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/input.json b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/input.json new file mode 100644 index 0000000000..7debde08eb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with the single word FIRST and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl new file mode 100644 index 0000000000..7e584998e7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -0,0 +1,238 @@ +{"type":"session","version":0,"id":"568e41cf-e2cb-4d96-b09a-9c37387af576","createdAt":1783095184776,"cwd":"/tmp/acp-snap-cwd-LVkJy5"} +{"type":"turn/start","seq":0,"time":1783095184779,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095184780,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095184781,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095185573,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095185573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095185713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095185735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095185736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095185736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095185736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1783095185757,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1783095185758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783095185779,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783095185780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1783095185780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1783095185780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":16,"time":1783095185780,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":17,"time":1783095185781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783095185801,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1783095185801,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":20,"time":1783095185802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1783095185802,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1783095185802,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":23,"time":1783095185802,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":24,"time":1783095185824,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":25,"time":1783095185824,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":26,"time":1783095185824,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":27,"time":1783095185824,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1783095185826,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1783095185826,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":30,"time":1783095185827,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} +{"type":"hook/result","seq":31,"time":1783095185846,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":19.14465199999995}} +{"type":"steering/message","seq":32,"time":1783095185846,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"step/start","seq":33,"time":1783095185847,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":34,"time":1783095186554,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":35,"time":1783095186554,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":36,"time":1783095186666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":37,"time":1783095186689,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" initially"}}} +{"type":"assistant/chunk","seq":38,"time":1783095186712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":39,"time":1783095186712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":40,"time":1783095186712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783095186712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1783095186712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1783095186713,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":44,"time":1783095186736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":45,"time":1783095186736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":46,"time":1783095186736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783095186737,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":48,"time":1783095186737,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":49,"time":1783095186758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":50,"time":1783095186758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":51,"time":1783095186759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":52,"time":1783095186759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" did"}}} +{"type":"assistant/chunk","seq":53,"time":1783095186759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1783095186759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":55,"time":1783095186781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} +{"type":"assistant/chunk","seq":56,"time":1783095186781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":57,"time":1783095186781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":58,"time":1783095186781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":59,"time":1783095186781,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":60,"time":1783095186809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" message"}}} +{"type":"assistant/chunk","seq":61,"time":1783095186809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":62,"time":1783095186831,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":63,"time":1783095186832,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":64,"time":1783095186832,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" also"}}} +{"type":"assistant/chunk","seq":65,"time":1783095186849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":66,"time":1783095186849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":67,"time":1783095186849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":68,"time":1783095186849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":69,"time":1783095186849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":70,"time":1783095186850,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1783095186872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":72,"time":1783095186872,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":73,"time":1783095186895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783095186895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" However"}}} +{"type":"assistant/chunk","seq":75,"time":1783095186895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":76,"time":1783095186895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":77,"time":1783095186918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":78,"time":1783095186918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":79,"time":1783095186918,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":80,"time":1783095186941,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":81,"time":1783095186941,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":82,"time":1783095186964,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":83,"time":1783095186964,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":84,"time":1783095186965,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":85,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":86,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":87,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":88,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} +{"type":"assistant/chunk","seq":89,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":90,"time":1783095186990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":91,"time":1783095187013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":92,"time":1783095187035,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":93,"time":1783095187036,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":94,"time":1783095187036,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" did"}}} +{"type":"assistant/chunk","seq":95,"time":1783095187059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":96,"time":1783095187059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":97,"time":1783095187059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":98,"time":1783095187081,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":99,"time":1783095187081,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":100,"time":1783095187104,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" trying"}}} +{"type":"assistant/chunk","seq":101,"time":1783095187105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":102,"time":1783095187105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" add"}}} +{"type":"assistant/chunk","seq":103,"time":1783095187128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":104,"time":1783095187128,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" additional"}}} +{"type":"assistant/chunk","seq":105,"time":1783095187151,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":106,"time":1783095187151,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":107,"time":1783095187174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":108,"time":1783095187174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":109,"time":1783095187174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":110,"time":1783095187174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":111,"time":1783095187174,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":112,"time":1783095187197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":113,"time":1783095187197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":114,"time":1783095187197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":115,"time":1783095187197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":116,"time":1783095187220,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":117,"time":1783095187260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":118,"time":1783095187261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":119,"time":1783095187261,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":120,"time":1783095187266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1783095187266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":122,"time":1783095187266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":123,"time":1783095187289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\".\n\n"}}} +{"type":"assistant/chunk","seq":124,"time":1783095187290,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} +{"type":"assistant/chunk","seq":125,"time":1783095187313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":126,"time":1783095187313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" looking"}}} +{"type":"assistant/chunk","seq":127,"time":1783095187336,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":128,"time":1783095187336,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":129,"time":1783095187336,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":130,"time":1783095187358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":131,"time":1783095187383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":132,"time":1783095187383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" responded"}}} +{"type":"assistant/chunk","seq":133,"time":1783095187405,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":134,"time":1783095187405,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":135,"time":1783095187405,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":136,"time":1783095187405,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":137,"time":1783095187406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1783095187429,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":139,"time":1783095187430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} +{"type":"assistant/chunk","seq":140,"time":1783095187430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" previous"}}} +{"type":"assistant/chunk","seq":141,"time":1783095187453,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" turn"}}} +{"type":"assistant/chunk","seq":142,"time":1783095187453,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":143,"time":1783095187453,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":144,"time":1783095187453,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":145,"time":1783095187453,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":146,"time":1783095187476,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":147,"time":1783095187476,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sending"}}} +{"type":"assistant/chunk","seq":148,"time":1783095187498,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":149,"time":1783095187499,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" new"}}} +{"type":"assistant/chunk","seq":150,"time":1783095187499,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" message"}}} +{"type":"assistant/chunk","seq":151,"time":1783095187499,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":152,"time":1783095187521,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":153,"time":1783095187521,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":154,"time":1783095187521,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":155,"time":1783095187521,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":156,"time":1783095187568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":157,"time":1783095187568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":158,"time":1783095187569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" consider"}}} +{"type":"assistant/chunk","seq":159,"time":1783095187569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":160,"time":1783095187569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":161,"time":1783095187594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":162,"time":1783095187594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":163,"time":1783095187594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":164,"time":1783095187594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":165,"time":1783095187616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":166,"time":1783095187617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":167,"time":1783095187617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Also"}}} +{"type":"assistant/chunk","seq":168,"time":1783095187637,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":169,"time":1783095187638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":170,"time":1783095187638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1783095187638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":172,"time":1783095187638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":173,"time":1783095187638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" SECOND"}}} +{"type":"assistant/chunk","seq":174,"time":1783095187659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":175,"time":1783095187659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":176,"time":1783095187659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":177,"time":1783095187659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":178,"time":1783095187660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":179,"time":1783095187683,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":180,"time":1783095187683,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":181,"time":1783095187683,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":182,"time":1783095187705,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":183,"time":1783095187705,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" request"}}} +{"type":"assistant/chunk","seq":184,"time":1783095187705,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":185,"time":1783095187705,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":186,"time":1783095187730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":187,"time":1783095187730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":188,"time":1783095187730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":189,"time":1783095187730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":190,"time":1783095187730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":191,"time":1783095187731,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":192,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":193,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":194,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":195,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":196,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":197,"time":1783095187753,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" did"}}} +{"type":"assistant/chunk","seq":198,"time":1783095187776,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":199,"time":1783095187776,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":200,"time":1783095187777,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":201,"time":1783095187800,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":202,"time":1783095187800,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":203,"time":1783095187800,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":204,"time":1783095187801,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" new"}}} +{"type":"assistant/chunk","seq":205,"time":1783095187801,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":206,"time":1783095187824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} +{"type":"assistant/chunk","seq":207,"time":1783095187824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":208,"time":1783095187824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":209,"time":1783095187845,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":210,"time":1783095187846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":211,"time":1783095187890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" think"}}} +{"type":"assistant/chunk","seq":212,"time":1783095187890,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":213,"time":1783095187891,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":214,"time":1783095187891,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":215,"time":1783095187891,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":216,"time":1783095187893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" new"}}} +{"type":"assistant/chunk","seq":217,"time":1783095187893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":218,"time":1783095187893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":219,"time":1783095187920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":220,"time":1783095187921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":221,"time":1783095187921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":222,"time":1783095187921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":223,"time":1783095187921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":224,"time":1783095187921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":225,"time":1783095187943,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":226,"time":1783095187943,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":227,"time":1783095187943,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":228,"time":1783095187944,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user initially asked me to reply with \"FIRST\" and stop, which I did. Now there's a plugin steering message asking me to also reply with \"SECOND\" and stop. However, the user's original instruction said \"Reply with the single word FIRST and stop.\" I already did that. The plugin is trying to add an additional instruction, but I should follow the original user instruction which said to stop after \"FIRST\".\n\nActually, looking more carefully - I already responded with \"FIRST\" in my previous turn. Now the user is sending a new message with a plugin steering. Let me consider what to do.\n\nThe plugin says \"Also reply with the single word SECOND, then stop.\" But the user's original request was to reply with \"FIRST\" and stop. I already did that. Now I have a new instruction from the plugin. I think I should follow this new instruction and reply with \"SECOND\"."}}}} +{"type":"assistant/chunk","seq":229,"time":1783095187944,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":230,"time":1783095187944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":82,"outputTokens":193,"cacheReadTokens":1664,"reasoningTokens":190}}}} +{"type":"assistant/chunk","seq":231,"time":1783095187944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":232,"time":1783095187944,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user initially asked me to reply with \"FIRST\" and stop, which I did. Now there's a plugin steering message asking me to also reply with \"SECOND\" and stop. However, the user's original instruction said \"Reply with the single word FIRST and stop.\" I already did that. The plugin is trying to add an additional instruction, but I should follow the original user instruction which said to stop after \"FIRST\".\n\nActually, looking more carefully - I already responded with \"FIRST\" in my previous turn. Now the user is sending a new message with a plugin steering. Let me consider what to do.\n\nThe plugin says \"Also reply with the single word SECOND, then stop.\" But the user's original request was to reply with \"FIRST\" and stop. I already did that. Now I have a new instruction from the plugin. I think I should follow this new instruction and reply with \"SECOND\"."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":193,"cacheReadTokens":1664,"reasoningTokens":190}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231],"surfaceOp":"append"} +{"type":"step/end","seq":233,"time":1783095187944,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":234,"time":1783095187944,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} +{"type":"hook/result","seq":235,"time":1783095187954,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":9.226673000000119}} +{"type":"turn/end","seq":236,"time":1783095187954,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl new file mode 100644 index 0000000000..a33b67ee1a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -0,0 +1,214 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" initially"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" did"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" message"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" also"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" However"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" did"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" trying"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" add"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" an"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" additional"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" looking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" responded"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" my"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" previous"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" turn"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sending"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" new"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" message"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" consider"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Also"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SECOND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" request"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" did"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" new"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" think"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" new"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/workspace/hooks.json new file mode 100644 index 0000000000..86ebf2ce39 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/workspace/hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "if [ -f .stop_fired ]; then exit 0; else touch .stop_fired; echo 'Also reply with the single word SECOND, then stop.' >&2; exit 2; fi" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl new file mode 100644 index 0000000000..ff774c4643 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -0,0 +1,454 @@ +{"type":"session","version":0,"id":"a78847be-1671-4fca-b6cb-08b7b5441592","createdAt":1783095422122,"cwd":"/tmp/acp-snap-cwd-Aqc9dB"} +{"type":"turn/start","seq":0,"time":1783095422127,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095422128,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095422128,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095422857,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095422857,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095422988,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095423013,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095423013,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095423014,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095423014,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095423014,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":11,"time":1783095423036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":12,"time":1783095423037,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":13,"time":1783095423037,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":14,"time":1783095423037,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":15,"time":1783095423037,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":16,"time":1783095423038,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1783095423064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095423087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":19,"time":1783095423087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1783095423087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783095423087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":22,"time":1783095423088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783095423088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":24,"time":1783095423111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":25,"time":1783095423112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":26,"time":1783095423112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095423189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1783095423189,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":29,"time":1783095423189,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":30,"time":1783095423189,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783095423208,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":32,"time":1783095423209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095423209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":34,"time":1783095423209,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095423231,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783095423232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":37,"time":1783095423232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":38,"time":1783095423232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":39,"time":1783095423232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095423283,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1783095423284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095423284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1783095423284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095423284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783095423308,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095423308,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":47,"time":1783095423332,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":48,"time":1783095423333,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":49,"time":1783095423333,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":50,"time":1783095423333,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":51,"time":1783095423333,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783095423357,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1783095423409,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783095423409,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1783095423410,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":56,"time":1783095423410,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1783095423411,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1783095423412,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1783095423425,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1783095423432,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":6.750618000000031}} +{"type":"tool/result","seq":61,"time":1783095423432,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783095423432,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1783095423433,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783095424086,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1783095424086,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1783095424308,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":67,"time":1783095424332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":68,"time":1783095424332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":69,"time":1783095424332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":70,"time":1783095424357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":71,"time":1783095424357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":72,"time":1783095424357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":73,"time":1783095424357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783095424357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":75,"time":1783095424358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":76,"time":1783095424384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":77,"time":1783095424384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" again"}}} +{"type":"assistant/chunk","seq":78,"time":1783095424384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":79,"time":1783095424384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":80,"time":1783095424406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}} +{"type":"assistant/chunk","seq":81,"time":1783095424406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":82,"time":1783095424406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":83,"time":1783095424439,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":84,"time":1783095424457,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1783095424506,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":86,"time":1783095424506,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":87,"time":1783095424531,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":88,"time":1783095424531,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783095424531,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":90,"time":1783095424556,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1783095424556,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":92,"time":1783095424556,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":93,"time":1783095424556,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":94,"time":1783095424580,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":95,"time":1783095424581,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":96,"time":1783095424581,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":97,"time":1783095424581,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":98,"time":1783095424581,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":99,"time":1783095424630,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":100,"time":1783095424630,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":101,"time":1783095424630,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":102,"time":1783095424630,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783095424631,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":104,"time":1783095424655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783095424655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":106,"time":1783095424655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":107,"time":1783095424655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":108,"time":1783095424655,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":109,"time":1783095424680,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":110,"time":1783095424680,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":111,"time":1783095424732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output was rejected by codex policy. Let me try again with the exact command as specified."}}}} +{"type":"assistant/chunk","seq":112,"time":1783095424732,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":113,"time":1783095424732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":55,"outputTokens":86,"cacheReadTokens":1792,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":114,"time":1783095424732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":115,"time":1783095424733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by codex policy. Let me try again with the exact command as specified."},{"type":"tool-call","id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":55,"outputTokens":86,"cacheReadTokens":1792,"reasoningTokens":20}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114],"surfaceOp":"append"} +{"type":"tool/call","seq":116,"time":1783095424733,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}} +{"type":"hook/invoked","seq":117,"time":1783095424745,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:2","matcher":"bash"}} +{"type":"hook/result","seq":118,"time":1783095424753,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":7.9194109999998545}} +{"type":"tool/result","seq":119,"time":1783095424753,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[116],"surfaceOp":"append"} +{"type":"step/end","seq":120,"time":1783095424753,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":121,"time":1783095424753,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":122,"time":1783095425881,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":123,"time":1783095425882,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":124,"time":1783095426030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":125,"time":1783095426055,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":126,"time":1783095426055,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":127,"time":1783095426056,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":128,"time":1783095426056,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":129,"time":1783095426056,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":130,"time":1783095426080,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":131,"time":1783095426104,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":132,"time":1783095426104,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":133,"time":1783095426104,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":134,"time":1783095426104,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":135,"time":1783095426104,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":136,"time":1783095426105,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":137,"time":1783095426128,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":138,"time":1783095426129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" slightly"}}} +{"type":"assistant/chunk","seq":139,"time":1783095426153,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":140,"time":1783095426153,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} +{"type":"assistant/chunk","seq":141,"time":1783095426153,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":142,"time":1783095426153,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" maybe"}}} +{"type":"assistant/chunk","seq":143,"time":1783095426154,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":144,"time":1783095426154,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":145,"time":1783095426177,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":146,"time":1783095426178,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":147,"time":1783095426178,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":148,"time":1783095426202,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" careful"}}} +{"type":"assistant/chunk","seq":149,"time":1783095426203,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":150,"time":1783095426203,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":151,"time":1783095426203,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} +{"type":"assistant/chunk","seq":152,"time":1783095426227,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":153,"time":1783095426251,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":154,"time":1783095426252,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":155,"time":1783095426352,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":156,"time":1783095426353,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":157,"time":1783095426377,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":158,"time":1783095426377,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1783095426377,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":160,"time":1783095426377,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":161,"time":1783095426378,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":162,"time":1783095426403,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":163,"time":1783095426404,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":164,"time":1783095426404,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":165,"time":1783095426404,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":166,"time":1783095426404,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":167,"time":1783095426427,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":168,"time":1783095426450,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":169,"time":1783095426478,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output is being rejected by codex policy. Let me try a slightly different approach - maybe I need to be more careful with the description or command."}}}} +{"type":"assistant/chunk","seq":170,"time":1783095426478,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":171,"time":1783095426478,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":163,"outputTokens":78,"cacheReadTokens":1792,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":172,"time":1783095426478,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":173,"time":1783095426479,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The tool output is being rejected by codex policy. Let me try a slightly different approach - maybe I need to be more careful with the description or command."},{"type":"tool-call","id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":163,"outputTokens":78,"cacheReadTokens":1792,"reasoningTokens":32}},"sourceEventSeqs":[122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172],"surfaceOp":"append"} +{"type":"tool/call","seq":174,"time":1783095426479,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}} +{"type":"hook/invoked","seq":175,"time":1783095426479,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:3","matcher":"bash"}} +{"type":"hook/result","seq":176,"time":1783095426488,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.644037999999455}} +{"type":"tool/result","seq":177,"time":1783095426488,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[174],"surfaceOp":"append"} +{"type":"step/end","seq":178,"time":1783095426488,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":179,"time":1783095426489,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":180,"time":1783095427124,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":181,"time":1783095427124,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":182,"time":1783095427272,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":183,"time":1783095427296,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":184,"time":1783095427296,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" consistently"}}} +{"type":"assistant/chunk","seq":185,"time":1783095427296,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} +{"type":"assistant/chunk","seq":186,"time":1783095427320,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":187,"time":1783095427349,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":188,"time":1783095427350,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":189,"time":1783095427350,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":190,"time":1783095427350,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":191,"time":1783095427350,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":192,"time":1783095427370,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":193,"time":1783095427370,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":194,"time":1783095427394,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" way"}}} +{"type":"assistant/chunk","seq":195,"time":1783095427419,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":196,"time":1783095427419,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" invoke"}}} +{"type":"assistant/chunk","seq":197,"time":1783095427443,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":198,"time":1783095427444,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":199,"time":1783095427517,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":200,"time":1783095427517,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":201,"time":1783095427542,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":202,"time":1783095427542,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":203,"time":1783095427542,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":204,"time":1783095427542,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":205,"time":1783095427542,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":206,"time":1783095427566,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":207,"time":1783095427566,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":208,"time":1783095427591,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":" '"}}} +{"type":"assistant/chunk","seq":209,"time":1783095427591,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"HE"}}} +{"type":"assistant/chunk","seq":210,"time":1783095427591,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":211,"time":1783095427591,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":212,"time":1783095427591,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"\\\\n"}}} +{"type":"assistant/chunk","seq":213,"time":1783095427615,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"'"}}} +{"type":"assistant/chunk","seq":214,"time":1783095427615,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":215,"time":1783095427640,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":216,"time":1783095427667,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool is consistently rejecting the output. Let me try a different way to invoke it."}}}} +{"type":"assistant/chunk","seq":217,"time":1783095427667,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}}}} +{"type":"assistant/chunk","seq":218,"time":1783095427668,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":67,"cacheReadTokens":1920,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":219,"time":1783095427668,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":220,"time":1783095427668,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The tool is consistently rejecting the output. Let me try a different way to invoke it."},{"type":"tool-call","id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}],"usage":{"inputTokens":135,"outputTokens":67,"cacheReadTokens":1920,"reasoningTokens":18}},"sourceEventSeqs":[180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219],"surfaceOp":"append"} +{"type":"tool/call","seq":221,"time":1783095427668,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}} +{"type":"hook/invoked","seq":222,"time":1783095427668,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:4","matcher":"bash"}} +{"type":"hook/result","seq":223,"time":1783095427677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.75036799999998}} +{"type":"tool/result","seq":224,"time":1783095427677,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[221],"surfaceOp":"append"} +{"type":"step/end","seq":225,"time":1783095427678,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":226,"time":1783095427678,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":227,"time":1783095428408,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":228,"time":1783095428408,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"Every"}}} +{"type":"assistant/chunk","seq":229,"time":1783095428535,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" attempt"}}} +{"type":"assistant/chunk","seq":230,"time":1783095428559,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":231,"time":1783095428559,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":232,"time":1783095428560,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":233,"time":1783095428560,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":234,"time":1783095428560,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":235,"time":1783095428583,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":236,"time":1783095428583,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":237,"time":1783095428583,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":238,"time":1783095428584,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":239,"time":1783095428623,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":240,"time":1783095428624,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":241,"time":1783095428632,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" happening"}}} +{"type":"assistant/chunk","seq":242,"time":1783095428656,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":243,"time":1783095428657,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" running"}}} +{"type":"assistant/chunk","seq":244,"time":1783095428679,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":245,"time":1783095428680,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} +{"type":"assistant/chunk","seq":246,"time":1783095428704,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":247,"time":1783095428704,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":248,"time":1783095428728,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":249,"time":1783095428728,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":250,"time":1783095428729,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" check"}}} +{"type":"assistant/chunk","seq":251,"time":1783095428752,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} +{"type":"assistant/chunk","seq":252,"time":1783095428752,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":253,"time":1783095428753,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" works"}}} +{"type":"assistant/chunk","seq":254,"time":1783095428776,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":255,"time":1783095428777,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" all"}}} +{"type":"assistant/chunk","seq":256,"time":1783095428777,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":257,"time":1783095428777,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":258,"time":1783095428777,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":"Let"}}} +{"type":"assistant/chunk","seq":259,"time":1783095428800,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" me"}}} +{"type":"assistant/chunk","seq":260,"time":1783095428801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" check"}}} +{"type":"assistant/chunk","seq":261,"time":1783095428801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" if"}}} +{"type":"assistant/chunk","seq":262,"time":1783095428824,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":263,"time":1783095428825,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" itself"}}} +{"type":"assistant/chunk","seq":264,"time":1783095428851,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":265,"time":1783095428852,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":" working"}}} +{"type":"assistant/chunk","seq":266,"time":1783095428852,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":267,"time":1783095428901,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":268,"time":1783095428901,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":269,"time":1783095428924,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":270,"time":1783095428925,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":271,"time":1783095428925,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":272,"time":1783095428925,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":273,"time":1783095428950,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":274,"time":1783095428951,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":275,"time":1783095428951,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"p"}}} +{"type":"assistant/chunk","seq":276,"time":1783095428951,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"wd"}}} +{"type":"assistant/chunk","seq":277,"time":1783095428973,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":278,"time":1783095428997,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":279,"time":1783095429024,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Every attempt is being rejected. Let me try to see what's happening by running a different command first, to check if bash works at all."}}}} +{"type":"assistant/chunk","seq":280,"time":1783095429024,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Let me check if bash itself is working:"}}}} +{"type":"assistant/chunk","seq":281,"time":1783095429024,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}}}} +{"type":"assistant/chunk","seq":282,"time":1783095429024,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":81,"cacheReadTokens":2048,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":283,"time":1783095429024,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":284,"time":1783095429025,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Every attempt is being rejected. Let me try to see what's happening by running a different command first, to check if bash works at all."},{"type":"text","text":"Let me check if bash itself is working:"},{"type":"tool-call","id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}],"usage":{"inputTokens":96,"outputTokens":81,"cacheReadTokens":2048,"reasoningTokens":29}},"sourceEventSeqs":[227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283],"surfaceOp":"append"} +{"type":"tool/call","seq":285,"time":1783095429025,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}} +{"type":"hook/invoked","seq":286,"time":1783095429025,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:5","matcher":"bash"}} +{"type":"hook/result","seq":287,"time":1783095429046,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":21.25910400000066}} +{"type":"tool/result","seq":288,"time":1783095429047,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[285],"surfaceOp":"append"} +{"type":"step/end","seq":289,"time":1783095429047,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":290,"time":1783095429047,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":291,"time":1783095430180,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":292,"time":1783095430181,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"It"}}} +{"type":"assistant/chunk","seq":293,"time":1783095430302,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} +{"type":"assistant/chunk","seq":294,"time":1783095430328,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" all"}}} +{"type":"assistant/chunk","seq":295,"time":1783095430350,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":296,"time":1783095430376,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":297,"time":1783095430376,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" are"}}} +{"type":"assistant/chunk","seq":298,"time":1783095430376,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":299,"time":1783095430377,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":300,"time":1783095430377,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":301,"time":1783095430401,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":302,"time":1783095430402,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":303,"time":1783095430402,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":304,"time":1783095430402,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":305,"time":1783095430426,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":306,"time":1783095430451,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"'m"}}} +{"type":"assistant/chunk","seq":307,"time":1783095430452,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} +{"type":"assistant/chunk","seq":308,"time":1783095430452,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":309,"time":1783095430476,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":310,"time":1783095430476,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":311,"time":1783095430501,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"sum"}}} +{"type":"assistant/chunk","seq":312,"time":1783095430502,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"mar"}}} +{"type":"assistant/chunk","seq":313,"time":1783095430502,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"ize"}}} +{"type":"assistant/chunk","seq":314,"time":1783095430502,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} +{"type":"assistant/chunk","seq":315,"time":1783095430526,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":316,"time":1783095430527,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" So"}}} +{"type":"assistant/chunk","seq":317,"time":1783095430552,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":318,"time":1783095430552,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":319,"time":1783095430552,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":320,"time":1783095430577,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" get"}}} +{"type":"assistant/chunk","seq":321,"time":1783095430577,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":322,"time":1783095430577,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" actual"}}} +{"type":"assistant/chunk","seq":323,"time":1783095430602,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":324,"time":1783095430602,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":325,"time":1783095430627,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":326,"time":1783095430627,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":327,"time":1783095430628,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":328,"time":1783095430628,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":329,"time":1783095430652,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":330,"time":1783095430653,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":331,"time":1783095430653,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":332,"time":1783095430653,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":333,"time":1783095430677,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":334,"time":1783095430678,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":335,"time":1783095430702,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":336,"time":1783095430703,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":337,"time":1783095430703,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":338,"time":1783095430727,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":339,"time":1783095430753,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":340,"time":1783095430753,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":341,"time":1783095430753,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":342,"time":1783095430780,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":343,"time":1783095430781,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":344,"time":1783095430781,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":345,"time":1783095430806,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":346,"time":1783095430806,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":347,"time":1783095430806,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"I"}}} +{"type":"assistant/chunk","seq":348,"time":1783095430806,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" ran"}}} +{"type":"assistant/chunk","seq":349,"time":1783095430831,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":350,"time":1783095430831,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} +{"type":"assistant/chunk","seq":351,"time":1783095430831,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" HE"}}} +{"type":"assistant/chunk","seq":352,"time":1783095430859,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":353,"time":1783095430859,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":354,"time":1783095430859,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":355,"time":1783095430859,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" via"}}} +{"type":"assistant/chunk","seq":356,"time":1783095430859,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":357,"time":1783095430881,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":358,"time":1783095430881,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":359,"time":1783095430881,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":360,"time":1783095430881,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" but"}}} +{"type":"assistant/chunk","seq":361,"time":1783095430881,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":362,"time":1783095430882,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":363,"time":1783095430907,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":364,"time":1783095430907,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":365,"time":1783095430907,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":366,"time":1783095430931,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":367,"time":1783095430932,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":368,"time":1783095430957,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":369,"time":1783095430957,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":370,"time":1783095430957,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":371,"time":1783095430958,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":372,"time":1783095430982,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":373,"time":1783095430982,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} +{"type":"assistant/chunk","seq":374,"time":1783095431007,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":375,"time":1783095431007,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" \""}}} +{"type":"assistant/chunk","seq":376,"time":1783095431008,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"sum"}}} +{"type":"assistant/chunk","seq":377,"time":1783095431008,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"mar"}}} +{"type":"assistant/chunk","seq":378,"time":1783095431008,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"ize"}}} +{"type":"assistant/chunk","seq":379,"time":1783095431008,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} +{"type":"assistant/chunk","seq":380,"time":1783095431032,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":381,"time":1783095431032,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"**"}}} +{"type":"assistant/chunk","seq":382,"time":1783095431057,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"Summary"}}} +{"type":"assistant/chunk","seq":383,"time":1783095431057,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":":**"}}} +{"type":"assistant/chunk","seq":384,"time":1783095431057,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" The"}}} +{"type":"assistant/chunk","seq":385,"time":1783095431082,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" command"}}} +{"type":"assistant/chunk","seq":386,"time":1783095431083,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":387,"time":1783095431083,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} +{"type":"assistant/chunk","seq":388,"time":1783095431083,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" HE"}}} +{"type":"assistant/chunk","seq":389,"time":1783095431107,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":390,"time":1783095431108,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":391,"time":1783095431108,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":392,"time":1783095431108,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":393,"time":1783095431108,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" executed"}}} +{"type":"assistant/chunk","seq":394,"time":1783095431108,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" successfully"}}} +{"type":"assistant/chunk","seq":395,"time":1783095431132,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":396,"time":1783095431133,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" Its"}}} +{"type":"assistant/chunk","seq":397,"time":1783095431157,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":398,"time":1783095431183,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" ("}}} +{"type":"assistant/chunk","seq":399,"time":1783095431183,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"the"}}} +{"type":"assistant/chunk","seq":400,"time":1783095431208,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" string"}}} +{"type":"assistant/chunk","seq":401,"time":1783095431233,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":402,"time":1783095431233,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} +{"type":"assistant/chunk","seq":403,"time":1783095431233,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":404,"time":1783095431234,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":405,"time":1783095431234,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"`)"}}} +{"type":"assistant/chunk","seq":406,"time":1783095431234,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":407,"time":1783095431259,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" not"}}} +{"type":"assistant/chunk","seq":408,"time":1783095431259,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" available"}}} +{"type":"assistant/chunk","seq":409,"time":1783095431285,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":410,"time":1783095431286,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" report"}}} +{"type":"assistant/chunk","seq":411,"time":1783095431310,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":412,"time":1783095431311,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":413,"time":1783095431311,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" because"}}} +{"type":"assistant/chunk","seq":414,"time":1783095431311,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":415,"time":1783095431311,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":416,"time":1783095431335,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"'s"}}} +{"type":"assistant/chunk","seq":417,"time":1783095431336,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":418,"time":1783095431360,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" was"}}} +{"type":"assistant/chunk","seq":419,"time":1783095431361,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} +{"type":"assistant/chunk","seq":420,"time":1783095431361,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":421,"time":1783095431361,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":422,"time":1783095431386,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":423,"time":1783095431387,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":424,"time":1783095431387,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" content"}}} +{"type":"assistant/chunk","seq":425,"time":1783095431411,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":426,"time":1783095431411,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":427,"time":1783095431411,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"I"}}} +{"type":"assistant/chunk","seq":428,"time":1783095431412,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":429,"time":1783095431453,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" report"}}} +{"type":"assistant/chunk","seq":430,"time":1783095431461,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":431,"time":1783095431462,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":432,"time":1783095431486,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":433,"time":1783095431487,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":434,"time":1783095431487,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" as"}}} +{"type":"assistant/chunk","seq":435,"time":1783095431487,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" requested"}}} +{"type":"assistant/chunk","seq":436,"time":1783095431487,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" —"}}} +{"type":"assistant/chunk","seq":437,"time":1783095431511,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":438,"time":1783095431512,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":439,"time":1783095431512,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" prevents"}}} +{"type":"assistant/chunk","seq":440,"time":1783095431537,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" it"}}} +{"type":"assistant/chunk","seq":441,"time":1783095431562,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":442,"time":1783095431562,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" Sto"}}} +{"type":"assistant/chunk","seq":443,"time":1783095431590,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"pping"}}} +{"type":"assistant/chunk","seq":444,"time":1783095431591,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":" here"}}} +{"type":"assistant/chunk","seq":445,"time":1783095431615,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":446,"time":1783095431616,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"It seems all tool outputs are being rejected by codex policy and I'm being told to \"summarize instead.\" So I can't get the actual output. Let me just report what I can: the command was run, but the output was rejected by policy."}}}} +{"type":"assistant/chunk","seq":447,"time":1783095431616,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"I ran `echo HELLO` via the bash tool, but the tool result was rejected by codex policy and I was instructed to \"summarize instead.\"\n\n**Summary:** The command `echo HELLO` was executed successfully. Its output (the string `HELLO`) is not available to report verbatim because the tool's output was blocked by the codex content policy.\n\nI cannot report the result verbatim as requested — the policy prevents it. Stopping here."}}}} +{"type":"assistant/chunk","seq":448,"time":1783095431616,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":199,"outputTokens":154,"cacheReadTokens":2048,"reasoningTokens":54}}}} +{"type":"assistant/chunk","seq":449,"time":1783095431616,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":450,"time":1783095431616,"data":{"turn":1,"step":6,"content":[{"type":"reasoning","text":"It seems all tool outputs are being rejected by codex policy and I'm being told to \"summarize instead.\" So I can't get the actual output. Let me just report what I can: the command was run, but the output was rejected by policy."},{"type":"text","text":"I ran `echo HELLO` via the bash tool, but the tool result was rejected by codex policy and I was instructed to \"summarize instead.\"\n\n**Summary:** The command `echo HELLO` was executed successfully. Its output (the string `HELLO`) is not available to report verbatim because the tool's output was blocked by the codex content policy.\n\nI cannot report the result verbatim as requested — the policy prevents it. Stopping here."}],"usage":{"inputTokens":199,"outputTokens":154,"cacheReadTokens":2048,"reasoningTokens":54}},"sourceEventSeqs":[291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449],"surfaceOp":"append"} +{"type":"step/end","seq":451,"time":1783095431616,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":452,"time":1783095431617,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl new file mode 100644 index 0000000000..bc189b2cc2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -0,0 +1,297 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" again"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exact"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specified"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vYk35aagabBlYGHTvJqn7418","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vYk35aagabBlYGHTvJqn7418","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" slightly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" maybe"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" careful"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" consistently"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" way"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" invoke"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf 'HELLO\\n'"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Every"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happening"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" running"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" check"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" works"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" all"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" check"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" if"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" itself"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" working"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"pwd"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"It"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" all"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" outputs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" are"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'m"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" told"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sum"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mar"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ize"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" get"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" actual"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ran"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" via"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"sum"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"mar"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ize"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"**"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Summary"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":**"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" executed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" string"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`)"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" available"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" content"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" requested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" prevents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Sto"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"pping"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" here"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/workspace/codex-hooks.json new file mode 100644 index 0000000000..e1da3228ba --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/workspace/codex-hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo 'tool output rejected by codex policy: summarize instead' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/input.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl new file mode 100644 index 0000000000..1a93694456 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -0,0 +1,112 @@ +{"type":"session","version":0,"id":"236baa98-470b-4ab5-84ef-8f1480d48cca","createdAt":1783095439420,"cwd":"/tmp/acp-snap-cwd-ikelet"} +{"type":"turn/start","seq":0,"time":1783095439424,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095439425,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095439426,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095440116,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095440116,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095440263,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095440290,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095440291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095440291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095440291,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095440292,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":11,"time":1783095440316,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":12,"time":1783095440317,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":13,"time":1783095440341,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":14,"time":1783095440342,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":15,"time":1783095440342,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":16,"time":1783095440342,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1783095440367,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1783095440397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":19,"time":1783095440397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1783095440398,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783095440398,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":22,"time":1783095440398,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783095440398,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":24,"time":1783095440418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":25,"time":1783095440418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":26,"time":1783095440418,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095440493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1783095440494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":29,"time":1783095440494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":30,"time":1783095440494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783095440518,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":32,"time":1783095440519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":33,"time":1783095440519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":34,"time":1783095440519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783095440544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":36,"time":1783095440545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":37,"time":1783095440545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":38,"time":1783095440545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":39,"time":1783095440545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095440596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":41,"time":1783095440596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783095440596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1783095440596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783095440596,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783095440621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783095440622,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":47,"time":1783095440622,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":48,"time":1783095440622,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":49,"time":1783095440649,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":50,"time":1783095440649,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":51,"time":1783095440649,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783095440672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":53,"time":1783095440716,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":54,"time":1783095440717,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1783095440717,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":56,"time":1783095440717,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1783095440719,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1783095440719,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} +{"type":"hook/invoked","seq":59,"time":1783095440731,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":60,"time":1783095440739,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.645890999999665}} +{"type":"tool/result","seq":61,"time":1783095440739,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"context/message","seq":62,"time":1783095440739,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1783095440739,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":64,"time":1783095440740,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":65,"time":1783095441383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":66,"time":1783095441383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":67,"time":1783095441531,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":68,"time":1783095441557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":69,"time":1783095441557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":70,"time":1783095441557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":71,"time":1783095441557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":72,"time":1783095441557,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":73,"time":1783095441582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":74,"time":1783095441582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":75,"time":1783095441582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":76,"time":1783095441582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":77,"time":1783095441582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":78,"time":1783095441583,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":79,"time":1783095441607,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":80,"time":1783095441607,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1783095441607,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":82,"time":1783095441608,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":83,"time":1783095441608,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":84,"time":1783095441608,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1783095441635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":86,"time":1783095441635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":87,"time":1783095441635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":88,"time":1783095441661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":89,"time":1783095441686,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} +{"type":"assistant/chunk","seq":90,"time":1783095441687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":91,"time":1783095441687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":92,"time":1783095441687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":93,"time":1783095441687,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":94,"time":1783095441687,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":95,"time":1783095441712,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":96,"time":1783095441712,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" returned"}}} +{"type":"assistant/chunk","seq":97,"time":1783095441738,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":98,"time":1783095441738,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":99,"time":1783095441739,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} +{"type":"assistant/chunk","seq":100,"time":1783095441739,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":101,"time":1783095441739,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":102,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":103,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":104,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to run `echo HELLO` and report the result verbatim. The result was \"HELLO\"."}}}} +{"type":"assistant/chunk","seq":105,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":106,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":70,"outputTokens":38,"cacheReadTokens":1792,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":107,"time":1783095441764,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":108,"time":1783095441765,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to run `echo HELLO` and report the result verbatim. The result was \"HELLO\"."},{"type":"text","text":"The tool returned:\n\n```\nHELLO\n```"}],"usage":{"inputTokens":70,"outputTokens":38,"cacheReadTokens":1792,"reasoningTokens":27}},"sourceEventSeqs":[65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} +{"type":"step/end","seq":109,"time":1783095441765,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":110,"time":1783095441765,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl new file mode 100644 index 0000000000..1639d59448 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -0,0 +1,65 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_TArPQZJxir9dawrAg0Fb9098","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_TArPQZJxir9dawrAg0Fb9098","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nHELLO\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/workspace/codex-hooks.json new file mode 100644 index 0000000000..ef832fc97a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/workspace/codex-hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"Note: command output has been verified against the audit log.\"}}'" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/input.json b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/input.json new file mode 100644 index 0000000000..3d44990f9b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl new file mode 100644 index 0000000000..0e6d3ac407 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -0,0 +1,109 @@ +{"type":"session","version":0,"id":"9bf49f12-d1ee-47dc-b827-c9815f5006b1","createdAt":1783095408281,"cwd":"/tmp/acp-snap-cwd-3JndXz"} +{"type":"turn/start","seq":0,"time":1783095408286,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095408287,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095408287,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095408978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095408978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095409180,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095409204,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095409205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095409205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095409205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783095409205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783095409206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":12,"time":1783095409229,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783095409253,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":14,"time":1783095409253,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1783095409254,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":16,"time":1783095409254,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1783095409280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":18,"time":1783095409281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":19,"time":1783095409281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":20,"time":1783095409281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1783095409355,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1783095409355,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":23,"time":1783095409379,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":24,"time":1783095409380,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783095409380,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":26,"time":1783095409380,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783095409380,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":28,"time":1783095409403,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783095409404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":30,"time":1783095409404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":31,"time":1783095409404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":32,"time":1783095409404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":33,"time":1783095409429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":34,"time":1783095409459,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":35,"time":1783095409460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":36,"time":1783095409460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":37,"time":1783095409460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1783095409460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":39,"time":1783095409477,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783095409478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"Print"}}} +{"type":"assistant/chunk","seq":41,"time":1783095409478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":42,"time":1783095409509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":43,"time":1783095409509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":44,"time":1783095409509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":45,"time":1783095409527,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":46,"time":1783095409527,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783095409552,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":48,"time":1783095409580,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"type":"assistant/chunk","seq":49,"time":1783095409580,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":50,"time":1783095409580,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":84,"cacheReadTokens":1664,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":51,"time":1783095409580,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":52,"time":1783095409582,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":72,"outputTokens":84,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1783095409582,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":54,"time":1783095409583,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":55,"time":1783095409598,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":15.254155000000083}} +{"type":"tool/result","seq":56,"time":1783095409599,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"step/end","seq":57,"time":1783095409599,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":58,"time":1783095409599,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":59,"time":1783095410756,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":60,"time":1783095410756,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":61,"time":1783095410916,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":62,"time":1783095410940,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":63,"time":1783095410940,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":64,"time":1783095410940,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" disabled"}}} +{"type":"assistant/chunk","seq":65,"time":1783095410941,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":66,"time":1783095410965,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":67,"time":1783095410966,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":68,"time":1783095410990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":69,"time":1783095410990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":70,"time":1783095410990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":71,"time":1783095410990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":72,"time":1783095410990,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":73,"time":1783095410991,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":74,"time":1783095411015,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":75,"time":1783095411041,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":76,"time":1783095411041,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":77,"time":1783095411041,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1783095411041,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":79,"time":1783095411065,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":80,"time":1783095411065,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":81,"time":1783095411065,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":82,"time":1783095411065,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":83,"time":1783095411065,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":84,"time":1783095411089,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" returned"}}} +{"type":"assistant/chunk","seq":85,"time":1783095411114,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":86,"time":1783095411114,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":87,"time":1783095411115,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Error"}}} +{"type":"assistant/chunk","seq":88,"time":1783095411115,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":89,"time":1783095411115,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} +{"type":"assistant/chunk","seq":90,"time":1783095411139,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":91,"time":1783095411139,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" disabled"}}} +{"type":"assistant/chunk","seq":92,"time":1783095411140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":93,"time":1783095411140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":94,"time":1783095411140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":95,"time":1783095411140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":96,"time":1783095411164,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" in"}}} +{"type":"assistant/chunk","seq":97,"time":1783095411165,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" this"}}} +{"type":"assistant/chunk","seq":98,"time":1783095411165,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" session"}}} +{"type":"assistant/chunk","seq":99,"time":1783095411165,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":100,"time":1783095411165,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":101,"time":1783095411196,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by codex policy. I need to report this verbatim to the user."}}}} +{"type":"assistant/chunk","seq":102,"time":1783095411196,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}}} +{"type":"assistant/chunk","seq":103,"time":1783095411196,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":41,"cacheReadTokens":1664,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":104,"time":1783095411196,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":105,"time":1783095411197,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool is disabled by codex policy. I need to report this verbatim to the user."},{"type":"text","text":"The tool returned:\n\n```\nError: bash is disabled by codex policy in this session\n```"}],"usage":{"inputTokens":180,"outputTokens":41,"cacheReadTokens":1664,"reasoningTokens":21}},"sourceEventSeqs":[59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104],"surfaceOp":"append"} +{"type":"step/end","seq":106,"time":1783095411197,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":107,"time":1783095411197,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl new file mode 100644 index 0000000000..f9f8fc7982 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -0,0 +1,62 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simple"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d1KxP9oXmTPECtwtxuVc9576","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d1KxP9oXmTPECtwtxuVc9576","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: bash is disabled by codex policy in this session\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/workspace/codex-hooks.json new file mode 100644 index 0000000000..e69b8fecbb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/workspace/codex-hooks.json @@ -0,0 +1,12 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash", + "hooks": [ + { "type": "command", "command": "echo 'bash is disabled by codex policy in this session' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/input.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/input.json new file mode 100644 index 0000000000..1995199566 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Delete everything in the repo." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl new file mode 100644 index 0000000000..bc9144f980 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook","durationMs":0}} +{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by codex policy hook"}} +{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by codex policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl new file mode 100644 index 0000000000..6f6e5b662f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json new file mode 100644 index 0000000000..84bc6f37d0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/input.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/input.json new file mode 100644 index 0000000000..348d8960d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "What is my favorite color? Reply with just the color and stop. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl new file mode 100644 index 0000000000..2e37a148e9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -0,0 +1,52 @@ +{"type":"session","version":0,"id":"09a657ad-72c0-4fd3-aae1-00b66dfdb3d5","createdAt":1783095399158,"cwd":"/tmp/acp-snap-cwd-rBmbYp"} +{"type":"turn/start","seq":0,"time":1783095399163,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"hook/invoked","seq":1,"time":1783095399164,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} +{"type":"hook/result","seq":2,"time":1783095399180,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":15.786223999999947}} +{"type":"user/message","seq":3,"time":1783095399180,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"context/message","seq":4,"time":1783095399180,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1783095399181,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":6,"time":1783095399936,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1783095399936,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":8,"time":1783095400054,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":9,"time":1783095400079,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":10,"time":1783095400103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":11,"time":1783095400104,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1783095400104,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1783095400104,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":14,"time":1783095400126,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":15,"time":1783095400127,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":16,"time":1783095400152,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" their"}}} +{"type":"assistant/chunk","seq":17,"time":1783095400153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" favorite"}}} +{"type":"assistant/chunk","seq":18,"time":1783095400153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" color"}}} +{"type":"assistant/chunk","seq":19,"time":1783095400153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783095400175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":21,"time":1783095400200,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":22,"time":1783095400200,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":23,"time":1783095400200,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":24,"time":1783095400224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":25,"time":1783095400224,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":26,"time":1783095400225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":27,"time":1783095400225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":28,"time":1783095400225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} +{"type":"assistant/chunk","seq":29,"time":1783095400250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tells"}}} +{"type":"assistant/chunk","seq":30,"time":1783095400250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":31,"time":1783095400250,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":32,"time":1783095400272,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" previously"}}} +{"type":"assistant/chunk","seq":33,"time":1783095400298,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stated"}}} +{"type":"assistant/chunk","seq":34,"time":1783095400299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" their"}}} +{"type":"assistant/chunk","seq":35,"time":1783095400299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" favorite"}}} +{"type":"assistant/chunk","seq":36,"time":1783095400299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" color"}}} +{"type":"assistant/chunk","seq":37,"time":1783095400299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":38,"time":1783095400299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" te"}}} +{"type":"assistant/chunk","seq":39,"time":1783095400321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"al"}}} +{"type":"assistant/chunk","seq":40,"time":1783095400321,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783095400321,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":42,"time":1783095400322,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":43,"time":1783095400346,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":44,"time":1783095400347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to reply with just their favorite color and stop, without using any tools. The context tells me they previously stated their favorite color is teal."}}}} +{"type":"assistant/chunk","seq":45,"time":1783095400347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":46,"time":1783095400347,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":86,"outputTokens":37,"cacheReadTokens":1664,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":47,"time":1783095400347,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":48,"time":1783095400349,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to reply with just their favorite color and stop, without using any tools. The context tells me they previously stated their favorite color is teal."},{"type":"text","text":"teal"}],"usage":{"inputTokens":86,"outputTokens":37,"cacheReadTokens":1664,"reasoningTokens":34}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":1783095400349,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":50,"time":1783095400350,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl new file mode 100644 index 0000000000..73a1557d3a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -0,0 +1,39 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" their"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" favorite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" color"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" context"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tells"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" they"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" previously"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stated"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" their"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" favorite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" color"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" te"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"al"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"te"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"al"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/workspace/codex-hooks.json new file mode 100644 index 0000000000..5d436f957c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/workspace/codex-hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo 'The user has previously stated their favorite color is teal.'" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/input.json b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/input.json new file mode 100644 index 0000000000..7debde08eb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with the single word FIRST and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl new file mode 100644 index 0000000000..09801dd4d9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -0,0 +1,247 @@ +{"type":"session","version":0,"id":"98a7b111-2254-4b0d-878a-0ada8517cbce","createdAt":1783095445945,"cwd":"/tmp/acp-snap-cwd-tCrxEw"} +{"type":"turn/start","seq":0,"time":1783095445950,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783095445951,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783095445952,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783095446380,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783095446381,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783095446470,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783095446495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783095446496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783095446496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783095446496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1783095446496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1783095446564,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783095446565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783095446565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1783095446565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1783095446565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":16,"time":1783095446565,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":17,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":20,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1783095446566,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":23,"time":1783095446569,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":24,"time":1783095446570,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":25,"time":1783095446570,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":26,"time":1783095446570,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":27,"time":1783095446570,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1783095446572,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1783095446573,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":30,"time":1783095446573,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} +{"type":"hook/result","seq":31,"time":1783095446588,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.944511000000148}} +{"type":"steering/message","seq":32,"time":1783095446588,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"step/start","seq":33,"time":1783095446589,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":34,"time":1783095447244,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":35,"time":1783095447244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":36,"time":1783095447361,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":37,"time":1783095447387,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":38,"time":1783095447411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":39,"time":1783095447411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":40,"time":1783095447411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":41,"time":1783095447411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":42,"time":1783095447412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":43,"time":1783095447440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":44,"time":1783095447440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":45,"time":1783095447440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":46,"time":1783095447440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":47,"time":1783095447440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":48,"time":1783095447441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783095447461,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":50,"time":1783095447461,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":51,"time":1783095447461,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":52,"time":1783095447486,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":53,"time":1783095447487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":54,"time":1783095447487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":55,"time":1783095447487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" did"}}} +{"type":"assistant/chunk","seq":56,"time":1783095447487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1783095447487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":58,"time":1783095447512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} +{"type":"assistant/chunk","seq":59,"time":1783095447512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":60,"time":1783095447512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":61,"time":1783095447512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":62,"time":1783095447536,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":63,"time":1783095447561,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" telling"}}} +{"type":"assistant/chunk","seq":64,"time":1783095447587,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":65,"time":1783095447587,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":66,"time":1783095447588,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":67,"time":1783095447588,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":68,"time":1783095447588,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":69,"time":1783095447588,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":70,"time":1783095447638,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":71,"time":1783095447639,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783095447639,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":73,"time":1783095447639,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":74,"time":1783095447639,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":75,"time":1783095447642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" However"}}} +{"type":"assistant/chunk","seq":76,"time":1783095447642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":77,"time":1783095447642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":78,"time":1783095447642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":79,"time":1783095447666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":80,"time":1783095447666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":81,"time":1783095447666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":82,"time":1783095447690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":83,"time":1783095447691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":84,"time":1783095447691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":85,"time":1783095447737,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":86,"time":1783095447738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":87,"time":1783095447738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":88,"time":1783095447738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":89,"time":1783095447738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":90,"time":1783095447738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":91,"time":1783095447752,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":92,"time":1783095447752,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":93,"time":1783095447778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":94,"time":1783095447778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} +{"type":"assistant/chunk","seq":95,"time":1783095447778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":96,"time":1783095447778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" complied"}}} +{"type":"assistant/chunk","seq":97,"time":1783095447803,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":98,"time":1783095447803,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":99,"time":1783095447803,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":100,"time":1783095447828,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \n\n"}}} +{"type":"assistant/chunk","seq":101,"time":1783095447828,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"But"}}} +{"type":"assistant/chunk","seq":102,"time":1783095447853,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":103,"time":1783095447853,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":104,"time":1783095447853,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":105,"time":1783095447853,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":106,"time":1783095447879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" injected"}}} +{"type":"assistant/chunk","seq":107,"time":1783095447904,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" via"}}} +{"type":"assistant/chunk","seq":108,"time":1783095447930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":109,"time":1783095447930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} +{"type":"assistant/chunk","seq":110,"time":1783095447930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" mechanism"}}} +{"type":"assistant/chunk","seq":111,"time":1783095447956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":112,"time":1783095447956,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":113,"time":1783095447981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":114,"time":1783095447981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":115,"time":1783095447981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783095447981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" latest"}}} +{"type":"assistant/chunk","seq":117,"time":1783095448004,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":118,"time":1783095448004,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":119,"time":1783095448029,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tells"}}} +{"type":"assistant/chunk","seq":120,"time":1783095448054,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":121,"time":1783095448054,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":122,"time":1783095448054,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":123,"time":1783095448055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":124,"time":1783095448055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":125,"time":1783095448055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":126,"time":1783095448079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":127,"time":1783095448080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783095448080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":129,"time":1783095448080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":130,"time":1783095448104,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":131,"time":1783095448104,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} +{"type":"assistant/chunk","seq":132,"time":1783095448152,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":133,"time":1783095448152,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} +{"type":"assistant/chunk","seq":134,"time":1783095448159,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":135,"time":1783095448159,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reconsider"}}} +{"type":"assistant/chunk","seq":136,"time":1783095448160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":137,"time":1783095448160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":138,"time":1783095448160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":139,"time":1783095448160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":140,"time":1783095448183,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" message"}}} +{"type":"assistant/chunk","seq":141,"time":1783095448184,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":142,"time":1783095448213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":143,"time":1783095448213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":144,"time":1783095448213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":145,"time":1783095448213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":146,"time":1783095448213,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":147,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":148,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} +{"type":"assistant/chunk","seq":149,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":150,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":151,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":152,"time":1783095448234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":153,"time":1783095448264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":154,"time":1783095448290,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" responded"}}} +{"type":"assistant/chunk","seq":155,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":156,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":157,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":158,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":159,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":160,"time":1783095448315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":161,"time":1783095448340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} +{"type":"assistant/chunk","seq":162,"time":1783095448340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":163,"time":1783095448340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":164,"time":1783095448340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":165,"time":1783095448365,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":166,"time":1783095448390,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" telling"}}} +{"type":"assistant/chunk","seq":167,"time":1783095448391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":168,"time":1783095448391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":169,"time":1783095448391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" also"}}} +{"type":"assistant/chunk","seq":170,"time":1783095448416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":171,"time":1783095448416,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":172,"time":1783095448417,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":173,"time":1783095448417,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":174,"time":1783095448441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":175,"time":1783095448441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":176,"time":1783095448441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":177,"time":1783095448441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":178,"time":1783095448441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":179,"time":1783095448467,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Since"}}} +{"type":"assistant/chunk","seq":180,"time":1783095448467,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":181,"time":1783095448467,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":182,"time":1783095448492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" interaction"}}} +{"type":"assistant/chunk","seq":183,"time":1783095448517,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":184,"time":1783095448544,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":185,"time":1783095448544,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" complete"}}} +{"type":"assistant/chunk","seq":186,"time":1783095448545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":187,"time":1783095448545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"I"}}} +{"type":"assistant/chunk","seq":188,"time":1783095448569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" already"}}} +{"type":"assistant/chunk","seq":189,"time":1783095448569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":190,"time":1783095448594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} +{"type":"assistant/chunk","seq":191,"time":1783095448594,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":192,"time":1783095448618,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stopped"}}} +{"type":"assistant/chunk","seq":193,"time":1783095448618,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":194,"time":1783095448619,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":195,"time":1783095448619,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" think"}}} +{"type":"assistant/chunk","seq":196,"time":1783095448619,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":197,"time":1783095448646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":198,"time":1783095448646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":199,"time":1783095448672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" trying"}}} +{"type":"assistant/chunk","seq":200,"time":1783095448672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":201,"time":1783095448672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" get"}}} +{"type":"assistant/chunk","seq":202,"time":1783095448697,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":203,"time":1783095448698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":204,"time":1783095448698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":205,"time":1783095448698,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" something"}}} +{"type":"assistant/chunk","seq":206,"time":1783095448723,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" additional"}}} +{"type":"assistant/chunk","seq":207,"time":1783095448748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":208,"time":1783095448748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":209,"time":1783095448748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":210,"time":1783095448748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":211,"time":1783095448773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":212,"time":1783095448774,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} +{"type":"assistant/chunk","seq":213,"time":1783095448774,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":214,"time":1783095448798,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":215,"time":1783095448823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":216,"time":1783095448823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":217,"time":1783095448823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":218,"time":1783095448848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} +{"type":"assistant/chunk","seq":219,"time":1783095448875,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":220,"time":1783095448875,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"I"}}} +{"type":"assistant/chunk","seq":221,"time":1783095448875,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":222,"time":1783095448875,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":223,"time":1783095448899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":224,"time":1783095448899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} +{"type":"assistant/chunk","seq":225,"time":1783095448899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":226,"time":1783095448899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" since"}}} +{"type":"assistant/chunk","seq":227,"time":1783095448924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":228,"time":1783095448924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":229,"time":1783095448924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":230,"time":1783095448949,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" most"}}} +{"type":"assistant/chunk","seq":231,"time":1783095448974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" recent"}}} +{"type":"assistant/chunk","seq":232,"time":1783095448974,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" directive"}}} +{"type":"assistant/chunk","seq":233,"time":1783095449000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":234,"time":1783095449000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":235,"time":1783095449000,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":236,"time":1783095449000,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":237,"time":1783095449028,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with the single word \"FIRST\" and stop, which I already did. Now there's a steering instruction telling me to reply with \"SECOND\" and stop. However, the user's original instruction was to reply with \"FIRST\" and stop - I've already complied with that. \n\nBut the steering instruction is injected via a plugin mechanism. I should follow the latest instruction which tells me to reply with \"SECOND\" and stop.\n\nActually, let me reconsider. The original user message said \"Reply with the single word FIRST and stop.\" I already responded with \"FIRST\". Now there's a steering instruction telling me to also reply with \"SECOND\" and stop. Since the original interaction is already complete (I already said FIRST and stopped), I think the steering is trying to get me to say something additional. But the user's original instruction said to stop after FIRST.\n\nI'll follow the steering instruction since it's the most recent directive."}}}} +{"type":"assistant/chunk","seq":238,"time":1783095449028,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":239,"time":1783095449028,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":82,"outputTokens":202,"cacheReadTokens":1664,"reasoningTokens":199}}}} +{"type":"assistant/chunk","seq":240,"time":1783095449029,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":241,"time":1783095449029,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to reply with the single word \"FIRST\" and stop, which I already did. Now there's a steering instruction telling me to reply with \"SECOND\" and stop. However, the user's original instruction was to reply with \"FIRST\" and stop - I've already complied with that. \n\nBut the steering instruction is injected via a plugin mechanism. I should follow the latest instruction which tells me to reply with \"SECOND\" and stop.\n\nActually, let me reconsider. The original user message said \"Reply with the single word FIRST and stop.\" I already responded with \"FIRST\". Now there's a steering instruction telling me to also reply with \"SECOND\" and stop. Since the original interaction is already complete (I already said FIRST and stopped), I think the steering is trying to get me to say something additional. But the user's original instruction said to stop after FIRST.\n\nI'll follow the steering instruction since it's the most recent directive."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":202,"cacheReadTokens":1664,"reasoningTokens":199}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240],"surfaceOp":"append"} +{"type":"step/end","seq":242,"time":1783095449029,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":243,"time":1783095449029,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} +{"type":"hook/result","seq":244,"time":1783095449039,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":9.63256199999978}} +{"type":"turn/end","seq":245,"time":1783095449039,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl new file mode 100644 index 0000000000..674613a8b9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -0,0 +1,223 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" did"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" telling"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" However"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ve"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" complied"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" injected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" via"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mechanism"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" latest"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tells"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reconsider"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" message"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" responded"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" telling"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" also"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Since"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" interaction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" complete"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" already"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stopped"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"),"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" think"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" trying"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" get"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" say"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" something"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" additional"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" But"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" since"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" most"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" recent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" directive"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/workspace/codex-hooks.json new file mode 100644 index 0000000000..86ebf2ce39 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/workspace/codex-hooks.json @@ -0,0 +1,11 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "if [ -f .stop_fired ]; then exit 0; else touch .stop_fired; echo 'Also reply with the single word SECOND, then stop.' >&2; exit 2; fi" } + ] + } + ] + } +} diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 6a156dcc48..4b15d2dc5a 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant. +The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. The UI is a terminal readline REPL. ## Run it @@ -8,10 +8,10 @@ The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:coding +pnpm run demo:repl ``` -Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. +Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ `bash_output` / `bash_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. ``` > fix the failing test in /path/to/project @@ -26,7 +26,7 @@ Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, set `RESUME_SESSION_ID` to that session's id — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: ```sh -RESUME_SESSION_ID=<prior-session-id> pnpm run demo:coding +RESUME_SESSION_ID=<prior-session-id> pnpm run demo:repl ``` The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. @@ -37,13 +37,14 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | 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 | +| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`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 | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | +| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | ## End-to-end tests (`pnpm run test:e2e`, key-gated) diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md new file mode 100644 index 0000000000..926cb56109 --- /dev/null +++ b/examples/coding-agent/composition.md @@ -0,0 +1,67 @@ +<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand. + Run `pnpm run gen-doc-graphs` to regenerate. --> + +# Coding Agent App Composition + +The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. + +```mermaid +flowchart LR + cfg["examples/coding-agent<br/>cordis.yml"] + plugin_coding_hmr["hmr<br/>@cordisjs/plugin-hmr"] + cfg --> plugin_coding_hmr + plugin_coding_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_coding_llm_deepseek + plugin_coding_bash["bash<br/>@deepseek-ai/dsh-bash-local"] + cfg --> plugin_coding_bash + plugin_coding_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-agent"] + cfg --> plugin_coding_stdio_agent + plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_coding_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_coding_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_coding_compact_basic + plugin_coding_subagent["subagent<br/>@deepseek-ai/dsh-subagent"] + cfg --> plugin_coding_subagent + plugin_coding_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_coding_subagent_spawn + plugin_coding_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_coding_subagent_fork + plugin_coding_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_coding_tool_subagent + plugin_coding_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_coding_tool_subagent_fork + plugin_coding_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_coding_tool_todo + plugin_coding_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"] + cfg --> plugin_coding_fs_local + plugin_coding_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_coding_fs_policy + plugin_coding_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_coding_tool_fs +``` + +| Plugin id | Package / module | +| --- | --- | +| `hmr` | `@cordisjs/plugin-hmr` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | + +Source config: [`examples/coding-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index e5bbea2811..0439262e33 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,4 +1,4 @@ -# The coding-agent plugin tree: the real coding agent. The two swappable +# The coding-agent plugin tree: the REPL agent demo. 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, @@ -6,7 +6,7 @@ # logger, JSONL persistence, the readline UI, and a pre-created `main` agent. # # `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 +# dev plugin that needs `--expose-internals` — the `demo:repl` 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. @@ -25,19 +25,18 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-flash - deepseek-v4-pro + - deepseek-v4-flash -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; subagent and todo_write are loaded below. +# Local bash executor for agent-core's tool-bash schema (one of several tool +# stacks in this tree: filesystem, subagent, and todo_write load below). - id: bash name: '@deepseek-ai/dsh-bash-local' config: timeoutMs: 60000 # The stdio chat app: the whole spine + front-door cluster, configured for a -# real coding agent driving a pre-created `main` agent. +# REPL agent demo driving a pre-created `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: @@ -46,32 +45,16 @@ # 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 (its tools are bash, subagent, and todo_write).' - systemPrompt: | - You are coding-agent, a CLI coding assistant. + welcome: 'agent REPL ready. Give it a coding task.' + # The persona: identity + behavior only, nothing about transports or + # tooling — tool guidance lives with each tool plugin (descriptions + + # prompt sections). {{model}} is the prompt variable the agent loop + # resolves from this agent's configured model. + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. - Your tools are bash (plus bash_output/bash_kill for background - tasks) and subagent. Do ALL file operations through bash: read with - cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > - file), edit with sed or a rewrite. Each bash call runs in a fresh - shell — pass workdir instead of cd, and never rely on shell state - between calls. - - Use the subagent tool to delegate a focused, self-contained subtask - to a fresh child agent (it works in its own context and returns only - its final result) — give it a complete, standalone instruction. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - Check the [exit code: N] marker on every command; investigate - failures before moving on. Verify your work by running the code or - tests. Keep answers brief and factual. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. + Verify your work by running the code or tests. Keep answers brief and + factual. # Automatic context compaction: when the derived history approaches the model's # context window, summarize an older range into a checkpoint so a long-running @@ -123,3 +106,17 @@ # session log (todo/write), rendered as a stdio checklist / ACP plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' + +# Filesystem capability stack: local provider, read-before-write/edit policy +# gate, then the model-facing read/write/edit tools. stdio-agent is a single +# session, so relative paths resolve from the process cwd (the workspace). +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/coding-agent/package.json b/examples/coding-agent/package.json index d92eeb6fdb..b3594ff597 100644 --- a/examples/coding-agent/package.json +++ b/examples/coding-agent/package.json @@ -3,5 +3,5 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Runnable demo: a real coding agent — DeepSeek V4 + the bash tool suite" + "description": "Runnable demo: an agent REPL UI with DeepSeek V4 and coding tools" } diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index 68bca5cdfa..ce716bdb2c 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -53,11 +53,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test const before = spawnSync('node', ['add.test.js'], { cwd: workdir }) expect(before.status).not.toBe(0) - ctx = await codingHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 2b8f278be3..cb7ce43811 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -53,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // budget even though those blocks are stripped before the checkpoint is // stored. ctx = await codingHarness(workdir, { + persona: SYSTEM_PROMPT, compact: { contextWindow: 2400, thresholdRatio: 0.5, @@ -63,10 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: './.sessions', }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 2b70d6f339..095d2a42a1 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -20,11 +20,8 @@ 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(AgentId('e2e-loop'), { - model: 'deepseek-v4-flash', - systemPrompt: SYSTEM_PROMPT, - }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 7ce24913cf..dd0bc42a1b 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -33,6 +33,11 @@ export const TODO_SYSTEM_PROMPT = 'You are a coding agent. For multi-step work, /** Options for {@link codingHarness}. */ export interface CodingHarnessOptions { + /** + * Deployment persona for the tree (the system-prompt plugin's `persona` + * config — per-context, not per-agent). Omitted ⇒ no persona section. + */ + persona?: string /** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */ persistenceRoot?: string /** @@ -47,7 +52,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index 4e5f3e78dc..dba8b140c7 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -9,8 +9,8 @@ import { afterEach, describe, expect, it } from 'vitest' * Keyless Loader-path smoke for examples/coding-agent: boot the REAL example * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the * cordis Loader, `unwrapExports`, the full plugin tree incl. the - * `@deepseek-ai/dsh-agent-core` bundle and the extracted - * `@deepseek-ai/dsh-ui-stdio`), then close stdin with no prompt and assert the + * `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI + * module), then close stdin with no prompt and assert the * ready banner + a clean exit. * * No prompt is ever sent, so the model is NEVER called — this is why it runs @@ -18,13 +18,14 @@ import { afterEach, describe, expect, it } from 'vitest' * `apply()` only requires a key to be PRESENT (it does not validate it and only * uses it when a stream actually starts), so a dummy key lets the tree boot * while the absence of any prompt guarantees no network call. The value is the - * real-Loader-path guard for the app + bundle + UI plugin export shapes (a broken - * `export default` that drops `inject`/`Config` would crash here — see postmortem - * 0001), complementing coding-agent's with-key e2e suites which prove the real + * real-Loader-path guard that the composed tree boots (see postmortem 0001; + * the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent + * unit suite's unwrap assertion, not by a crash here), + * complementing coding-agent's with-key e2e suites which prove the real * product. */ -// The dsh-stdio-agent bin (the demo:coding entry) and this example's cordis.yml. +// The dsh-stdio-agent bin (the demo:repl 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)) @@ -51,7 +52,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { return new Promise((resolve, reject) => { const proc = spawn( process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding). + // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:repl). ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, @@ -94,6 +95,6 @@ describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { const { stdout, code } = await bootAndEof() expect(code).toBe(0) - expect(stdout).toContain('coding-agent ready.') + expect(stdout).toContain('agent REPL ready.') }, 15_000) }) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 4be11ed3ea..fc216a9848 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -38,11 +38,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 1: a fresh agent on a KNOWN session id learns a secret, then we // dispose the whole context (simulating process exit) so only the JSONL // log on disk survives. - ctx = await codingHarness(process.cwd(), { persistenceRoot: root }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = ctx.agents.create({ agentId: AgentId('resume-1'), sessionId: SESSION_ID, - agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, + agentOptions: { model: 'deepseek-v4-flash' }, }).agent as ReactLoopAgent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) @@ -52,11 +52,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // Run 2: a brand-new context over the SAME root resumes the persisted // 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(), { persistenceRoot: root }) + ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, - agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT }, + agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index 33cac531cf..b100091a0f 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -18,11 +18,8 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => { it('appends a todo/write event with the model-produced task list', async () => { - ctx = await codingHarness(process.cwd()) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { - model: 'deepseek-v4-flash', - systemPrompt: TODO_SYSTEM_PROMPT, - }) + ctx = await codingHarness(process.cwd(), { persona: TODO_SYSTEM_PROMPT }) + const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md new file mode 100644 index 0000000000..1491c56955 --- /dev/null +++ b/examples/echo-agent/composition.md @@ -0,0 +1,40 @@ +<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand. + Run `pnpm run gen-doc-graphs` to regenerate. --> + +# Echo Agent App Composition + +The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door. + +```mermaid +flowchart LR + cfg["examples/echo-agent<br/>cordis.yml"] + plugin_echo_hmr["hmr<br/>@cordisjs/plugin-hmr"] + cfg --> plugin_echo_hmr + plugin_echo_mock_llm["mock-llm<br/>./src/mock-llm.ts"] + cfg --> plugin_echo_mock_llm + plugin_echo_echo_tool["echo-tool<br/>./src/echo-tool.ts"] + cfg --> plugin_echo_echo_tool + plugin_echo_bash["bash<br/>@deepseek-ai/dsh-bash-local"] + cfg --> plugin_echo_bash + plugin_echo_stdio_agent["stdio-agent<br/>@deepseek-ai/dsh-stdio-agent"] + cfg --> plugin_echo_stdio_agent + plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-core"] + plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_echo_stdio_agent --> frontdoor_stdio["readline UI<br/>console logger<br/>pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] +``` + +| Plugin id | Package / module | +| --- | --- | +| `hmr` | `@cordisjs/plugin-hmr` | +| `mock-llm` | `./src/mock-llm.ts` | +| `echo-tool` | `./src/echo-tool.ts` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-agent` | + +Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 9eef3d1a1b..b66c5e8163 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -33,6 +33,6 @@ name: '@deepseek-ai/dsh-stdio-agent' config: model: mock-echo - systemPrompt: 'You are echo-agent, a demo agent.' + persona: 'You are echo-agent, a demo agent.' welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).' persistenceRoot: './.sessions' diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 944a36e429..01bb34bff0 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -13,11 +13,12 @@ import { afterEach, describe, expect, it } from 'vitest' * * This is the guard the per-file unit suite structurally cannot be: it drives * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core` - * bundle it loads, the extracted `@deepseek-ai/dsh-ui-stdio` plugin, AND the - * 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 + * bundle it loads, the app's in-package readline UI module, AND the + * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path + * (see docs/postmortem/0001). The app itself carries no `inject`, so a stray + * `export default` would boot rather than crash here — the export SHAPE is + * pinned by the explicit unwrap assertion in the stdio-agent unit suite; this + * smoke proves the composed tree actually runs. It needs no API key — the * `mock-echo` adapter never touches the network — so it runs in the default e2e * gate. * diff --git a/knip.json b/knip.json index 67d99a861d..5e61645458 100644 --- a/knip.json +++ b/knip.json @@ -29,6 +29,18 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/web/web-search-exa": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/web/web-search-perplexity": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/web/web-search-deepseek": { + "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"] @@ -44,6 +56,10 @@ "packages/subagent/subagent-acp": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/fs/tool-fs": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/package.json b/package.json index 7d82af4aad..7453a022e9 100644 --- a/package.json +++ b/package.json @@ -29,35 +29,47 @@ "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-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", + "verify-rfc-format": "tsx scripts/verify-rfc-format.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", + "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", + "verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check", + "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", + "verify-persistence-catalog": "tsx scripts/gen-persistence-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-tool-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", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", - "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", + "demo:repl": "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": { "@agentclientprotocol/sdk": "0.25.1", "@stylistic/eslint-plugin": "^5.10.0", + "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", "@types/node": "^25.3.5", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", "fast-check": "^4.8.0", + "jsdom": "29.1.1", "knip": "^6.16.1", "lefthook": "^2.1.9", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", + "mermaid": "11.16.0", "micromark-extension-gfm": "^3.0.0", "publint": "^0.3.21", "tsdown": "^0.22.2", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 62d37a3354..ac26b926f7 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -1,18 +1,16 @@ # AGENTS.md — Harness Packages -This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing code here, follow these conventions: +This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific. -- **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup. -- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants. -- **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.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` 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.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). -- **Tests**: vitest in `packages/<group>/<pkg>/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. +- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md). 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` 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). + +- 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 (the export-shape rule above). +- `src/types.ts` contains only types — no runtime code. +- Tests live at package level under `tests/`, not `src/__tests__/`. +- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; prose accuracy stays on the author ([the documentation standard](../docs/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 ba86e886ae..ad27017a0e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,109 +1,34 @@ # Packages -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()`. +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, declares its ctx key/events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) (subtree) and the root [AGENTS.md](../AGENTS.md) § Conventions. ## Hierarchy -Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure. +Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code. | 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 | +| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | +| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface | | [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | Product — stable surface | +| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | -| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | +| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface | +| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free | -The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs). +The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table). -## Dependency graph +## Dependencies -``` -dsh-brand (no harness deps — type-only Branded<B> 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-brand -dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred) -dsh-session-fork ← dsh-session (live-session fork snapshots + child session creation) -dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend) -dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent -dsh-bash-local ← dsh-bash (BashExecutor impl) -dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) -dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) -dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) -dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent -dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) -dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence, dsh-tools (ACP JSON-RPC bridge) -dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) -dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) -dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam) -dsh-subagent-inprocess ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (shared in-process run driver) -dsh-subagent-mock ← dsh-subagent, dsh-agent, dsh-llm (scripted provider for tests) -dsh-subagent-spawn ← dsh-subagent, dsh-subagent-inprocess (in-process fresh child backend) -dsh-subagent-fork ← dsh-subagent, dsh-subagent-inprocess, dsh-agent, dsh-session (in-process child seeded from parent log) -dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP) -dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) -dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) -dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) -dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) -dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) -``` +The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -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 - -| 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` | -| `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`) | -| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | -| `session-fork/` | `session-fork` | Session fork service over live session seeds | `ctx.sessionFork` | -| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | -| `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`) | -| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | -| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | -| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | -| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | -| `subagent-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) | -| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | -| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) | -| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | -| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -| `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) | -| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | +The rule it must obey: **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)). Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). - -## Conventions (applied across all harness packages) - -- **Registrations are effects**: every contribution (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal and HMR clean up automatically. Every `register()` returns the disposer. -- **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`. -- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). -- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. -- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package. -- **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 016f57d2a9..dec29ce93b 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -12,6 +12,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L timeoutMs: 120000 # default foreground timeout maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills ``` ## Behavior (and where it came from) @@ -19,11 +20,11 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. +- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. -- **Model-friendly env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. +- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. ## Sandboxing -`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Wrap the `tools/execute` waterfall (veto/ask) or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine. +`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § Extending The Harness. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 05f1ed75dd..cdac3985b8 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -4,9 +4,10 @@ * own process group (see `./run.ts` for the plumbing and the agent-tool * survey notes), tracks background tasks, and kills everything on dispose. * - * TODO(permissions/sandbox): execution policy does NOT belong here — wrap - * the `tools/execute` waterfall (see docs/architecture.md § plugin - * checklist) or implement a sandboxing `BashExecutor`. Reference points: + * TODO(permissions/sandbox): execution policy does NOT belong here — use + * the `tools/pre-execute` deny/ask gate (see docs/architecture.md + * § Extending The Harness) or implement a sandboxing `BashExecutor`. + * Reference points: * Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies * seatbelt/landlock plus an execpolicy prefix-rule engine. * @@ -17,7 +18,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' -import { runBash } from './run.ts' +import { DEFAULT_GRACE_MS, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' @@ -33,6 +34,8 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number + /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + graceMs?: number } /** The shape after schemastery applied the defaults (cwd has none). */ @@ -57,7 +60,7 @@ interface TrackedTask extends BashTask { * Local-subprocess bash executor. Defaults follow the agent-tool survey * consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB * in-memory output with full-stream spill files (pi, OpenCode), - * process-group SIGTERM→SIGKILL kills (OpenCode). + * process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode). */ export class LocalBashExecutor extends BashExecutor { static Config: z<Config> = z.object({ @@ -65,11 +68,12 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), + graceMs: z.number().default(DEFAULT_GRACE_MS), }) private tasks = new Map<BashTaskId, TrackedTask>() private nextTaskId = 1 - /** Test seam: timer/spill knobs forwarded to runBash. */ + /** Test seam: spill knobs forwarded to runBash. */ internals: RunInternals = {} /** Validated config (schemastery applied the defaults before construction). */ @@ -83,6 +87,7 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('graceMs', this.config.graceMs) ctx.effect(() => async () => { // Kill every live process group and WAIT for the processes to close so // nothing outlives the fiber (HMR safety) — a TERM-trapping child is @@ -116,6 +121,10 @@ export class LocalBashExecutor extends BashExecutor { workdir: request.workdir ?? this.config.cwd ?? process.cwd(), timeoutMs, ...request.signal ? { signal: request.signal } : {}, + // Carry stdin/env through verbatim — optional, no config default (absent + // means none). env merges AFTER the scrub in run.ts. + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, // 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, @@ -128,7 +137,10 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, timeoutMs: spec.timeoutMs, maxOutputBytes: this.config.maxOutputBytes, + graceMs: this.config.graceMs, signal: spec.signal, + stdin: spec.stdin, + env: spec.env, }, this.internals).done return { ...outcome, timeoutMs: spec.timeoutMs } } @@ -144,7 +156,10 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, timeoutMs: 0, maxOutputBytes: this.config.maxOutputBytes, + graceMs: this.config.graceMs, signal: spec.signal, + stdin: spec.stdin, + env: spec.env, }, this.internals) const id = BashTaskId(`bash-${this.nextTaskId++}`) diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 8a8d2065d2..489d380787 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -15,7 +15,8 @@ * @module dsh-bash-local/run */ -import { spawn } from 'node:child_process' +import { type ChildProcessByStdio, spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -42,13 +43,26 @@ export const ENV_OVERRIDES = { */ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i -/** process.env minus credential-shaped vars, plus the model-friendly overrides. */ -export function childEnv(): NodeJS.ProcessEnv { +/** + * `process.env` minus credential-shaped vars, plus the model-friendly + * overrides, plus any caller-supplied `extra` entries. + * + * Layering matters: the scrub drops `process.env` credentials, then + * `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is + * merged LAST so an explicit caller entry wins even when its name matches the + * scrub pattern (the scrub is the control that stops the HARNESS's ambient + * credentials leaking into a spawned command; a caller that explicitly sets a + * var named a value it already holds, not that ambient secret). `extra` is set + * by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash` + * builds its request from named fields only and does not forward model input + * here (see its README, § "The tool builds its request from named args only"). + */ +export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value } - return { ...env, ...ENV_OVERRIDES } + return { ...env, ...ENV_OVERRIDES, ...extra } } /** What to run and under which limits (resolved — no defaults in here). */ @@ -59,8 +73,23 @@ export interface SpawnSpec { timeoutMs: number /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */ maxOutputBytes: number + /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + graceMs: number /** Abort signal — kills the process group when fired. */ signal?: AbortSignal | undefined + /** + * Bytes to write to the child's stdin, then close it. Absent (or empty) + * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); + * the model-facing `dsh-tool-bash` tool does not thread model input here. + */ + stdin?: string | undefined + /** + * Extra environment entries, merged onto the scrubbed env AFTER the + * credential scrub and the model-friendly overrides (so an explicit entry + * wins). Set by in-process plugins; the model-facing tool does not forward + * model input here. + */ + env?: Record<string, string> | undefined } /** Raw outcome of one closed process (before result shaping). */ @@ -73,15 +102,13 @@ export interface SpawnOutcome { stderr: CollectedOutput } -/** Injectable knobs so tests can exercise escalation/spill without long waits. */ +/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ export interface RunInternals { - /** Grace period between SIGTERM and SIGKILL on the process group. */ - graceMs?: number /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string } -/** Default SIGTERM→SIGKILL grace period (matches OpenCode's 3s). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ export const DEFAULT_GRACE_MS = 3_000 let spillCounter = 0 @@ -265,19 +292,26 @@ export interface RunningBash { * no inherited shell state); revisit when real workflows demand it. */ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { - const graceMs = internals.graceMs ?? DEFAULT_GRACE_MS const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } - const child = spawn('bash', ['-c', spec.command], { - cwd: spec.cwd, - env: childEnv(), - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - }) + // stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore` + // (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe + // and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX + // socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat + // /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path + // (every model-driven call) must keep /dev/null rather than regress to a socket. + // Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the + // typed `spawn` overload infer non-null stdout/stderr, which the + // `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/ + // stderr the non-null `Readable` the collectors attach to without a cast). + const env = childEnv(spec.env) + const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined + ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) + : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir) const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir) @@ -296,7 +330,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB const kill = (): void => { if (graceTimer !== undefined) return // escalation already in flight killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, graceMs) + graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } if (spec.timeoutMs > 0) { @@ -312,6 +346,24 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } spec.signal?.addEventListener('abort', onAbort, { once: true }) + // Write stdin and close it, but ONLY when the caller supplied bytes — with no + // stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error + // handler must exist whenever we write: an unhandled 'error' on the stream + // would throw and crash the host. We swallow the error rather than reject + // `done`, and that is correct for ANY stdin-write error, not just the common + // one — the stdin write is BEST-EFFORT, while the command's authoritative + // outcome is its exit code + captured output, which the `close` handler reports + // regardless of whether the write landed. The expected case is EPIPE (the child + // exited without reading, so closing our end of a still-full pipe fails); a rare + // non-EPIPE pipe fault means the command ran with incomplete stdin, and it + // surfaces that itself through its own exit/output (e.g. a hook that gets + // truncated JSON errors out) — rejecting here would instead discard that real + // output and turn it into an opaque infrastructure error, which is worse. + if (child.stdin !== null) { + child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + child.stdin.end(spec.stdin) + } + const done = new Promise<SpawnOutcome>((resolve, reject) => { child.on('error', (error) => { // Spawn-level failure (ENOENT cwd, EACCES, …): no close event with diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2a27f29731..ce89b2a0ae 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -11,9 +11,10 @@ const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) { const ctx = new Context() - await ctx.plugin(LocalBashExecutor, config) + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } return { ctx, bash } } @@ -80,12 +81,22 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) }) + it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => { + const { bash } = await setup() // setup pins graceMs: 200 via config + const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' })) + await new Promise(resolve => setTimeout(resolve, 100)) + bash.kill(task.id) + await task.done + expect(task.signal).toBe('SIGKILL') + }) + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { const { bash } = await setup({ timeoutMs: 60_000 }) const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 })) @@ -106,6 +117,23 @@ describe('LocalBashExecutor.run', () => { const { bash } = await setup() await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) }) + + it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } }) + // resolve() keeps the stdin/env fields verbatim (optional, no default). + expect(spec.stdin).toBe('piped\n') + expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' }) + const result = await bash.run(spec) + expect(result.stdout.text).toBe('piped\n[env-ok]\n') + }) + + it('resolve() omits stdin/env when the request supplies neither', async () => { + const { bash } = await setup() + const spec = bash.resolve({ command: 'true' }) + expect('stdin' in spec).toBe(false) + expect('env' in spec).toBe(false) + }) }) describe('LocalBashExecutor background tasks', () => { @@ -131,6 +159,19 @@ describe('LocalBashExecutor background tasks', () => { await Promise.all([first.done, second.done]) }) + it('threads stdin and extra env into a background task', async () => { + const { bash } = await setup() + const task = bash.start(bash.resolve({ + command: 'cat; echo "[$DSH_BG_VAR]"', + stdin: 'bg-stdin\n', + env: { DSH_BG_VAR: 'bg-env' }, + })) + const read = await readUntil(bash, task.id, '[bg-env]') + expect(read.delta).toContain('bg-stdin') + await task.done + expect(task.exitCode).toBe(0) + }) + it('readOutput returns increments without re-delivery', async () => { const { bash } = await setup() const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' })) @@ -241,9 +282,9 @@ describe('LocalBashExecutor background tasks', () => { it('disposing with already-finished tasks only kills the running ones', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'true' })) await finished.done @@ -258,9 +299,9 @@ describe('LocalBashExecutor background tasks', () => { it('disposing the executor fiber kills running tasks (no orphans)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const listener = vi.fn() bash.onTaskDone(listener) @@ -307,9 +348,9 @@ describe('review fixes: lifecycle hardening', () => { it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' })) await new Promise(resolve => setTimeout(resolve, 100)) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index b770c4a6c1..d2888e2fee 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -28,6 +28,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> cwd: process.cwd(), timeoutMs: 0, maxOutputBytes: 64_000, + graceMs: 3_000, ...overrides, } } @@ -106,7 +107,7 @@ describe('runBash', () => { }) it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 }) + const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.kill() const result = await running.done @@ -158,6 +159,62 @@ describe('runBash', () => { }) }) +describe('stdin and extra env (set by in-process plugins)', () => { + it('writes stdin to the command and closes it', async () => { + const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('hello from stdin\n') + }) + + it('a command that reads stdin sees EOF when none is supplied', async () => { + // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no + // output (it does NOT block). + const result = await runBash(spec('cat')).done + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('') + }) + + it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + // The no-stdin path must stay observationally identical to the pre-seam + // `ignore` default: a command that probes stdin's file type sees a char + // device (/dev/null). Regressing to an always-open pipe would make fd 0 a + // socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping + // `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied, + // fd 0 is that pipe (a socket), as it must be to carry them. + const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done + expect(none.stdout.text).toBe('char\n') + const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + expect(piped.stdout.text).toBe('socket\n') + }) + + it('merges extra env entries onto the scrubbed environment', async () => { + const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', { + env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' }, + })).done + expect(result.stdout.text).toBe('alpha/beta\n') + }) + + it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { + // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. + // DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit + // entry is still honored — the scrub only drops AMBIENT process.env creds. + const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', { + env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' }, + })).done + expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') + }) + + it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { + // The child exits immediately without reading; closing our end of a stdin + // pipe still holding ~1MiB triggers EPIPE on the write. The handler must + // swallow it: `done` resolves normally with the child's real exit. + const big = 'x'.repeat(1024 * 1024) + const result = await runBash(spec('exit 7', { stdin: big })).done + expect(result.exitCode).toBe(7) + expect(result.aborted).toBe(false) + }) +}) + describe('output truncation and spill', () => { it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 6123565e7a..39318ae371 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -28,4 +28,6 @@ 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** (`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. +`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, 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. + +`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 01c5c081c3..b629f10e4d 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service { * call this, then pass the result to {@link run}/{@link start} — keeping * defaulting in the implementation that owns the config while the seam type * stays explicit (no hidden `?? default` inside run/start). + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. */ abstract resolve(request: BashExecRequest): BashExecSpec - /** Run a command in the foreground; resolves when it finishes. */ + /** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise<BashRunResult> - /** Start a background task and return its handle immediately. */ + /** + * Start a background task and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live task handle; completion fires {@link onTaskDone}. + */ abstract start(spec: BashExecSpec): BashTask - /** Look up a background task by id. */ + /** + * Look up a background task by id. + * @param id - the task id to look up. + * @returns the tracked task, or undefined for an id this executor never issued. + */ abstract get(id: BashTaskId): BashTask | undefined /** @@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service { * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task"). * Storing ownership in the executor (disposed with ITS fiber) — not in the * tool plugin — is what makes ownership survive a `tool-bash` HMR reload. + * @param id - the background task id to look up ownership for. + * @returns the token recorded at start, verbatim; undefined for an unknown + * id or a known-but-ownerless task. */ abstract ownerOf(id: BashTaskId): OwnerToken | undefined - /** All tracked background tasks (insertion order). */ + /** + * All tracked background tasks (insertion order). + * @returns every task this executor started, running or finished. + */ abstract list(): BashTask[] - /** Read output produced since the previous read. Throws for unknown ids. */ + /** + * Read output produced since the previous read. Throws for unknown ids. + * @param id - the task to read from. + * @returns the incremental read; consecutive reads never re-deliver output. + */ abstract readOutput(id: BashTaskId): BashTaskRead /** * Kill a running background task. Returns false when it had already * finished (no-op). Throws for unknown ids. + * @param id - the task to kill. + * @returns true when this call killed it, false when it had already finished. */ abstract kill(id: BashTaskId): boolean /** * Register a background-task completion listener (disposed with the * calling fiber). Listeners never fire after this service is disposed. + * @param listener - called exactly once per task completion. + * @returns the disposer that unregisters the listener. */ onTaskDone(listener: BashTaskListener): () => void { const dispose = this.ctx.effect(() => { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index d9ab9f9b4d..9acd5c7cb7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -45,6 +45,24 @@ export interface BashExecRequest { timeoutMs?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin, then close it. Absent leaves stdin + * closed/empty (the default for model-driven tool calls). Set by in-process + * plugins (e.g. the hooks bridges, which write a hook command's JSON payload + * to its stdin); the model-facing bash tool does not expose it as a parameter + * (a model that needs stdin uses shell syntax like a heredoc or a pipe). + */ + stdin?: string | undefined + /** + * Extra environment entries for the command, merged AFTER the + * implementation's credential scrub (so an explicit entry here is honored even + * when its name matches the scrub pattern — the caller named a value it holds, + * not the harness's ambient secret). Set by in-process plugins (the hooks + * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing + * bash tool does not expose it as a parameter (a model that needs an env var + * uses shell syntax like `FOO=bar cmd`). + */ + env?: Record<string, string> | 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 @@ -70,6 +88,22 @@ export interface BashExecSpec { timeoutMs: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin (then close it), carried through + * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec + * (unlike `owner`): it has no config default, so a missing one means "no + * stdin" — the safe, ordinary case — not a silent footgun, so it stays a + * plain optional rather than required-but-nullable (see the request field). + */ + stdin?: string | undefined + /** + * Extra environment entries, carried through verbatim from + * {@link BashExecRequest.env} and merged by the implementation AFTER its + * credential scrub (an explicit entry wins even when its name matches the + * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no + * config default, absent means "no extra env". + */ + env?: Record<string, string> | undefined /** * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` * being required on the resolved spec): {@link BashExecutor.resolve} carries diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 656a22cdb1..eabb9298aa 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -2,7 +2,9 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. -Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`). +Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). + +The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. ## Tools @@ -34,12 +36,16 @@ 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/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"). +These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. 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 instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and 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 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`. +## The tool builds its request from named args only + +The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). + ## Permissions -`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work. +`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work. diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index f9092fabb6..d8836d6a21 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 9ad1f9a17c..478ed70ca5 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -31,9 +31,9 @@ * 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 + * permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus * sandboxing `BashExecutor` implementations — see docs/architecture.md - * § plugin checklist. + * § Extending The Harness. * * @module @deepseek-ai/dsh-tool-bash */ @@ -41,13 +41,14 @@ import type { Context } from 'cordis' 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 { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-system-prompt' import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' -export const inject = ['tools', 'bash'] +export const inject = ['tools', 'bash', 'systemPrompt'] /** * Validate the constraints the SchemaSpec can't express. `defineTool` now @@ -158,16 +159,26 @@ export function renderResult(result: BashRunResult): string { */ type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean } -function presentBashCall(args: BashCallArgs): ToolCallPresentation { - const base = { - title: args.command, - kind: 'execute' as const, - rawInput: args.command, - content: [{ type: 'text' as const, text: args.description }], +function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView { + // A background start is not an interactive terminal — a generic execute card + // with the command as rawInput and the description as a content block. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + // A foreground run IS a terminal: the command titles the card, the description + // renders above it, and the cwd (when the model gave a workdir) heads it. + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, } - // A background start is not an interactive terminal — no terminal card. - if (args.run_in_background === true) return base - return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} } } /** @@ -186,21 +197,25 @@ function presentBashCall(args: BashCallArgs): ToolCallPresentation { * task-id ack, not a streamed run) and an `isError` result (a spawn failure or * abort — there is no real process exit to pill, and the body is an error * message, not `renderResult` output, so parsing it would be meaningless). Those - * fall back to the fenced `content` block with no terminal metadata. The bridge's - * orphan guard also drops a result terminal when the call wasn't terminal, so a - * background call (not marked terminal in `presentBashCall`) is doubly safe. - * A non-text result (unexpected for bash) falls through to `undefined`. + * return a `generic` result whose content is the fenced ```console block. A + * finished foreground run returns a `terminal` result carrying the RAW output + * and the parsed exit status; the BRIDGE derives the fenced fallback from + * `output` for a UI without terminal support, so the tool does not double-encode + * it. A non-text result (unexpected for bash) falls through to `undefined`. */ -function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined { +function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined const raw = block.text - const fenced = raw.replace(/\n+$/, '') - const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }] const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true - // No exit pill / terminal output for a background ack or an errored run. - if (isBackground || result.isError) return { content } - return { content, terminal: { output: raw, ...parseExitStatus(raw) } } + // A background ack or an errored run is not a real terminal exit: render the + // fenced ```console fallback as generic content (no exit pill). + if (isBackground || result.isError) { + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } + } + // A finished foreground run: RAW output + parsed exit for the terminal card. + // The bridge derives the no-capability fenced fallback from `output`. + return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } /** @@ -237,8 +252,8 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string } /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */ -function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation { - return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } +function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView { + return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } } /** @@ -271,6 +286,15 @@ function statusLine(task: BashTask): string { } export function apply(ctx: Context): void { + // The bash tools' cross-call HABIT, which the per-tool descriptions cannot + // carry (they describe one call each): the exit-code marker is only useful + // if the model actually checks it every time. + ctx.systemPrompt.section({ + name: 'tool:bash', + order: 105, + text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.', + }) + /** * The caller's owner TOKEN — the owning agent's `session.header.id`, or * `undefined` for a non-agent caller. Read `session.header.id` (NOT diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index a67809ffee..b3d6bb3f77 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -123,7 +123,10 @@ describe('bash tool through the agent loop', () => { textResponse('Background task finished.'), ]) // The second tool call needs the REAL task id from the first result; - // a tools/execute waterfall listener rewrites the scripted arguments. + // a tools/pre-execute listener rewrites the scripted arguments. (This uses + // the low-level capability to mutate `exec` before dispatch — the + // unadvertised mechanism behind a future first-class input-rewrite decision; + // here it is a test shim to thread the generated id, not a product feature.) let taskId = '' const ctx = await harness(adapter) @@ -137,7 +140,7 @@ describe('bash tool through the agent loop', () => { if (match) taskId = match[1]! } }) - ctx.on('tools/execute', async (exec, next) => { + ctx.on('tools/pre-execute', async (exec, next) => { if (exec.name === 'bash_output') { exec.arguments = { task_id: taskId } } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9de079fd33..7d4b34f74f 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -21,8 +21,8 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -184,8 +184,8 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -261,6 +261,14 @@ describe('bash tool', () => { }) }) + it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => { + const ctx = await setup() + const assembly = await ctx.systemPrompt.assemble() + const section = assembly.sections.find(s => s.name === 'tool:bash') + expect(section?.order).toBe(105) + expect(section?.text).toContain('[exit code: N]') + }) + it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -268,8 +276,11 @@ describe('bash tool', () => { await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(3) + expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) + // Only the system-prompt plugin's own built-in sections remain. + expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona']) }) it('tools depend on the executor: no registration without ctx.bash', async () => { @@ -316,8 +327,8 @@ describe('background tools', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true }) @@ -568,8 +579,8 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } const fiber = await ctx.plugin(ToolBash) const a = fakeAgent('sess-a') @@ -716,45 +727,40 @@ describe('status lines', () => { }) describe('tool-owned UI presentation (presentCall / presentResult)', () => { - it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => { + it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => { const ctx = await setup() - // No explicit workdir → the call still flags a terminal, but with no cwd (the - // UI bridge fills the session cwd it owns; the pure presenter can't see it). - // The command is the title (an execute card hides rawInput); the description - // rides as a content text block (shown above the terminal card). + // No explicit workdir → a terminal card with no cwd (the UI bridge fills the + // session cwd it owns; the pure presenter can't see it). expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' })) - .toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} }) + .toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' }) // An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' })) - .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } }) + .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: '/tmp/x' }) // A RELATIVE workdir is passed through AS-IS (the bridge resolves it against // the session cwd, matching where execution runs) — not dropped. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' })) - .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } }) + .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' }) }) - it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => { + it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( { command: 'echo hi', description: 'echo' }, { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false }, ) - // The fenced ```console content trims trailing blank lines for a tidy block; - // terminal.output keeps the RAW bytes (newlines intact) a terminal renderer - // needs; exitCode is parsed back from the [exit code: N] marker. - expect(present).toEqual({ - content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }], - terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }, - }) + // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer + // needs; the bridge derives the fenced fallback. exitCode is parsed back from + // the [exit code: N] marker. + expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }) }) it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { const ctx = await setup() const args = { command: 'x', description: 'x' } const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }) - expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 }) + expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 }) const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }) - expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) + expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) }) it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => { @@ -779,7 +785,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { for (const c of cases) { const rendered = renderResult(c.result) const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) - const { output: _o, ...exit } = out?.terminal ?? {} + // Drop card + output; the remaining fields are the parsed exit. + const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } expect(exit).toEqual(c.expect) } }) @@ -793,44 +800,42 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // the marker (renderResult always inserts one before a REAL marker), so this // no-trailing-newline body is NOT mistaken for a failure → exitCode 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) - expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 }) + expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) // Same for a fake signal marker with no leading newline. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) - expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 }) + expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) }) - it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => { + it('bash presentCall/presentResult: a run_in_background call is a generic card and its ack carries no exit pill', async () => { const ctx = await setup() - // The background start returns a task-id ack, not a streamed run — no terminal. + // The background start returns a task-id ack, not a streamed run — a generic + // execute card with the command as rawInput and the description as content. const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true }) - expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] }) - expect((call as { terminal?: unknown }).terminal).toBeUndefined() - // The ack result is fenced text only — no terminal output / exit pill. + expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] }) + // The ack result is a generic fenced-text card — no terminal output / exit pill. const result = ctx.tools.get('bash')!.presentResult!( { command: 'sleep 100', description: 'wait', run_in_background: true }, { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false }, ) - expect(result?.terminal).toBeUndefined() - expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }]) + expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task bash-1\n```' }] }) }) - it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => { + it('bash presentResult: an isError result is a generic card (no real process exit to report)', async () => { const ctx = await setup() // A spawn failure / abort has no process exit — the body is an error message, - // not renderResult output, so no terminal output/exit is emitted. + // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, { content: [{ type: 'text', text: 'command aborted' }], isError: true }, ) - expect(out?.terminal).toBeUndefined() - expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }]) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'image', url: 'https://x/y.png' }], isError: false }, + { content: [{ type: 'reasoning', text: 'unexpected' }], isError: false }, ) expect(present).toBeUndefined() }) @@ -849,9 +854,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => { const ctx = await setup() expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' })) - .toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' }) + .toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' }) expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' })) - .toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' }) + .toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' }) }) it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => { @@ -863,3 +868,105 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined() }) }) + +describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => { + /** + * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a + * test can assert what the model-facing tool DID and DID NOT forward. The `bash` + * tool does not expose `stdin`/`env` as parameters (bash syntax already gives a + * model that power), so it must build its request from named args only and + * never spread unknown tool-call keys into it. This guard's job is to catch a + * future refactor that blindly forwards `...args` — which would silently thread + * model input into the post-scrub `env` merge — NOT to defend a trust boundary + * (the credential scrub in dsh-bash-local is the security control; see the + * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is + * unused here. + */ + class RecordingBashExecutor extends BashExecutor { + readonly requests: BashExecRequest[] = [] + resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 0, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + owner: request.owner, + } + } + run(): Promise<BashRunResult> { + return Promise.resolve({ + exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0, + stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, + }) + } + start(): BashTask { throw new Error('unused') } + get(): BashTask | undefined { return undefined } + ownerOf(): OwnerToken | undefined { return undefined } + list(): BashTask[] { return [] } + readOutput(): BashTaskRead { throw new Error('unused') } + kill(): boolean { return false } + } + + async function setupRecording() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(RecordingBashExecutor) + await ctx.plugin(ToolBash) + return { ctx, bash: ctx.bash as RecordingBashExecutor } + } + + it('does not forward env/stdin even when the model includes them as extra arguments', async () => { + const { ctx, bash } = await setupRecording() + // Extra args: the model includes `env` and `stdin` keys hoping they reach the + // executor. The bash tool's schema ignores unknown keys, and execute() builds + // the request from only command/workdir/timeoutMs/signal — so the recorded + // request carries NEITHER. (Not a security wall — the model could set an env + // var or feed stdin via shell syntax anyway; this just keeps the request + // shape honest so a future `...args` spread can't silently forward input.) + await ctx.tools.execute({ + callId: CallId('no-forward-1'), + name: 'bash', + arguments: { + command: 'echo hi', + description: 'echo', + env: { SNEAKY_API_KEY: 'leak' }, + stdin: 'malicious payload', + }, + }) + expect(bash.requests).toHaveLength(1) + const request = bash.requests[0]! + expect(request.command).toBe('echo hi') + expect('env' in request).toBe(false) + expect('stdin' in request).toBe(false) + }) + + it('a background bash call likewise carries no env/stdin', async () => { + const { ctx, bash } = await setupRecording() + // start() throws in this recorder, but resolve() runs first and records the + // request — which is all this no-forward assertion needs. + await ctx.tools.execute({ + callId: CallId('no-forward-2'), + name: 'bash', + arguments: { + command: 'sleep 1', + description: 'sleep', + run_in_background: true, + env: { TOKEN: 'leak' }, + stdin: 'x', + }, + }) + expect(bash.requests).toHaveLength(1) + const request = bash.requests[0]! + expect('env' in request).toBe(false) + expect('stdin' in request).toBe(false) + // The owner token IS set on a background call (the isolation fence) — proving + // the recorder sees the real request the consumer built, so the absent + // env/stdin above is a real negative, not a recorder that drops everything. + expect('owner' in request).toBe(true) + }) +}) diff --git a/packages/compact/README.md b/packages/compact/README.md index 10eaf1617a..08c3ddd707 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index c195ae42fb..81163973fc 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline. +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization routed through the agent request pipeline. This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf The abstract contract states only WHAT compaction does; this backend owns every HOW decision: -- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). +- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). - **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. - **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. -- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. +- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it. - **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event. - **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README). - **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. @@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju | `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. | | `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | +| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. | ## Usage diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index c019796e0d..e57e28a8c9 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-compact-basic", - "description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", + "description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f53dace461..2bc7b65a80 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -2,7 +2,8 @@ * `BasicCompactService`: the first implementation of the * `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy: * - * - **Token estimation** — char/4 heuristic with per-block structural overhead. + * - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4) + * with per-block structural overhead. * - **Retention policy** — walk surface nodes tail→head, keep recent nodes up * to a token budget, compact everything older. The cutoff is snapped forward * to the next balanced tool-pairing boundary so a compacted region never @@ -44,9 +45,6 @@ export { resolveConfig } from './types.ts' /** Per-block structural overhead for JSON framing / type tag. */ const BLOCK_OVERHEAD = 4 -/** Heuristic token count for an image block (~85 tokens for low-res URL). */ -const IMAGE_TOKEN_COST = 85 - /** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */ const ROLE_OVERHEAD = 4 @@ -148,9 +146,11 @@ function finishError(finish: FinishReason): Error | undefined { } /** - * Basic, dependency-light compaction backend. Defaults target a 128K context - * window, compacting at 80% utilization and retaining ~20K tokens of recent - * context. + * Basic, dependency-light compaction backend: estimates the surface's token + * footprint, summarizes the stale prefix through the model, and shadows it + * behind a durable checkpoint. Every threshold/budget knob is required config + * ({@link BasicCompactConfig}); the estimator's text density is the + * `charsPerToken` knob. */ export class BasicCompactService extends CompactService { static inject = ['llm'] @@ -207,36 +207,36 @@ export class BasicCompactService extends CompactService { // ---- Token estimation (overridable hooks) ---- - // TODO: char/4 is a coarse heuristic. Replace with an exact count — a real - // tokenizer, or the provider's post-response `usage` (input tokens) fed back - // as a correction — so threshold decisions match the model's actual budget. + // TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact + // count — a real tokenizer, or the provider's post-response `usage` (input + // tokens) fed back as a correction — so threshold decisions match the + // model's actual budget. /** - * Estimate the token count of content blocks — char/4 with per-block - * overhead. Override in a subclass to plug in a real tokenizer. + * Estimate the token count of content blocks — chars divided by the + * `charsPerToken` config, with per-block overhead. Override in a subclass to + * plug in a real tokenizer. */ estimateContentTokens(blocks: readonly ContentBlock[]): number { + const { charsPerToken } = this.config let tokens = 0 for (const block of blocks) { switch (block.type) { case 'text': case 'reasoning': - tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD + tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD break case 'tool-call': - tokens += Math.ceil(block.name.length / 4) - + Math.ceil(block.arguments.length / 4) + tokens += Math.ceil(block.name.length / charsPerToken) + + Math.ceil(block.arguments.length / charsPerToken) + BLOCK_OVERHEAD break case 'tool-result': tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD break - case 'image': - tokens += IMAGE_TOKEN_COST - break default: // Unknown block types (merge-extensible ContentBlockMap): // estimate conservatively via JSON stringify. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4) + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken) } } return tokens @@ -266,7 +266,7 @@ export class BasicCompactService extends CompactService { total += this.estimateContentTokens(msg.content) total += ROLE_OVERHEAD } - if (systemPrompt) total += Math.ceil(systemPrompt.length / 4) + if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken) return total } @@ -706,10 +706,10 @@ export class BasicCompactService extends CompactService { /** * Render content blocks to a single plain-text string for the summarization * prompt. Text and reasoning contribute their text; every other block type - * contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, - * …) so the summarizer is told what non-text content existed in the region - * rather than silently losing it. Blocks join with newlines; empty-text - * blocks contribute nothing. + * contributes a type-tagged placeholder (`[tool-call: name(args)]`, + * `[tool-result: …]`, …) so the summarizer is told what non-text content + * existed in the region rather than silently losing it. Blocks join with + * newlines; empty-text blocks contribute nothing. */ private _blocksToText(blocks: readonly ContentBlock[]): string { const parts: string[] = [] @@ -729,9 +729,6 @@ export class BasicCompactService extends CompactService { parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') break } - case 'image': - parts.push('[image]') - break // ContentBlockMap is merge-extensible — render an unknown block as a // bare type-tagged placeholder so a plugin-added block type is still // signalled to the summarizer rather than dropped. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 98195d8883..2f10084ac3 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -10,10 +10,12 @@ */ /** - * Backend configuration. Every knob is REQUIRED except `auto`: there is no - * concrete data yet to justify default thresholds/budgets, so a consumer must - * state each value explicitly rather than inherit a guessed default. `auto` - * alone defaults to `true` (auto-compaction is the intended posture). + * Backend configuration. Every knob is REQUIRED except `auto` and + * `charsPerToken`: there is no concrete data yet to justify default + * thresholds/budgets, so a consumer must state each value explicitly rather + * than inherit a guessed default. `auto` alone defaults to `true` + * (auto-compaction is the intended posture), and `charsPerToken` defaults to + * the English-text heuristic its estimator was calibrated on. */ export interface BasicCompactConfig { /** Context window size in tokens. */ @@ -30,13 +32,21 @@ export interface BasicCompactConfig { compactionRetries: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean + /** + * Text density for the token estimator: estimated tokens = chars / + * `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy + * deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so + * the default UNDERestimates several-fold and compaction fires far too late. + * May be fractional. + */ + charsPerToken?: number } -/** Resolved config with `auto` defaulted. */ +/** Resolved config with `auto` and `charsPerToken` defaulted. */ export type ResolvedConfig = Required<BasicCompactConfig> /** - * Default `auto` when unset and reject nonsensical numeric knobs. + * Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs. * * Convergence is not a static config invariant: provider generation caps can be * spent on hidden or surfaced reasoning tokens, and the model may emit a summary @@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig> * throwing if the surface still exceeds the threshold. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - const resolved: ResolvedConfig = { auto: true, ...config } + const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config } assertPositiveInteger('contextWindow', resolved.contextWindow) assertRatio('thresholdRatio', resolved.thresholdRatio) assertNonNegativeInteger('retainTokens', resolved.retainTokens) assertPositiveInteger('maxTokens', resolved.maxTokens) assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + assertPositiveFinite('charsPerToken', resolved.charsPerToken) if (typeof resolved.summarizationModel !== 'string') { throw new Error('BasicCompactConfig: summarizationModel must be a string.') } @@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void { } } +function assertPositiveFinite(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`) + } +} + function assertRatio(name: string, value: number): void { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1929e656be..80c35d4b06 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -811,15 +811,23 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { ])).toBe(10) }) - it('estimates image blocks at fixed 85 tokens', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85) - }) - it('returns 0 for empty content blocks', () => { const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([])).toBe(0) }) + + it('honors a configured charsPerToken (fractional densities included)', () => { + // 'this is a somewhat longer text block' = 36 chars. + const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }] + // charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate. + const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 })) + expect(dense.estimateContentTokens(blocks)).toBe(22) + // Fractional density is legal: ceil(36/1.5)+4 = 28. + const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 })) + expect(fractional.estimateContentTokens(blocks)).toBe(28) + // The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18. + expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18) + }) }) describe('BasicCompactService HMR safety', () => { @@ -862,6 +870,10 @@ describe('BasicCompactService config validation', () => { )).toThrow(/summarizationModel must be a string/) expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>))) .toThrow(/auto must be a boolean/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 }))) + .toThrow(/charsPerToken .* positive finite number/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN }))) + .toThrow(/charsPerToken .* positive finite number/) }) it('accepts a large retain budget because convergence is enforced dynamically', () => { @@ -1324,7 +1336,7 @@ describe('BasicCompactService edge cases', () => { s.append('assistant/message', { turn: 1, step: 1, content: [ - { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, + { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] }, { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, { type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' }, ], @@ -1343,7 +1355,7 @@ describe('BasicCompactService edge cases', () => { const nodes = s.surface.nodes await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! - expect(text).toContain('[tool-result: [image]]') // nested tool-result with content + expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content expect(text).toContain('[custom-widget]') // unknown block placeholder expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder }) @@ -1510,25 +1522,28 @@ describe('BasicCompactService edge cases', () => { it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { const svc = createTestService() const s = new Session(SessionId('placeholders')) + // A plugin-added block type (merge-extensible ContentBlockMap) — the + // placeholder path must cover every message kind, not just assistant. + const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) - // user/message with only an image block → '[image]' placeholder. - s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // assistant/message with an image block AND the tool-call its tool/result - // answers (so the surface is tool-pairing balanced) → '[image]' placeholder. + // user/message with only a plugin-added block → '[chart]' placeholder. + s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' }) + // assistant/message with a plugin-added block AND the tool-call its + // tool/result answers (so the surface is tool-pairing balanced). s.append('assistant/message', { turn: 1, step: 1, content: [ - { type: 'image', url: 'https://x/z.png' }, + chart('z'), { type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' }, ], }, { surfaceOp: 'append' }) - // tool/result with an image block → '[image]' placeholder. + // tool/result with a plugin-added block → '[chart]' placeholder. s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' }) - // context/message and steering/message with image content. - s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' }) + // context/message and steering/message with plugin-added content. + s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/end', { turn: 1, step: 1 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -1537,11 +1552,11 @@ describe('BasicCompactService edge cases', () => { await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! // Every non-text block surfaces as a placeholder rather than being dropped. - expect(text).toContain('User: [image]') - expect(text).toContain('Assistant: [image]') - expect(text).toContain('Tool result (call e1): [image]') - expect(text).toContain('[Context: [image]]') - expect(text).toContain('[Steering: [image]]') + expect(text).toContain('User: [chart]') + expect(text).toContain('Assistant: [chart]') + expect(text).toContain('Tool result (call e1): [chart]') + expect(text).toContain('[Context: [chart]]') + expect(text).toContain('[Steering: [chart]]') }) }) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index b6f3cc0920..b49070c3b5 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| | `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` | -| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | +| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). @@ -43,13 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta ## Events -The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`: - -| Event | Payload | On surface? | -|---|---|---| -| `compact/start` | `{ turn }` | no (log-only) | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) | -| `compact/end` | `{ turn, error? }` | no (log-only) | +The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). ## Implementing a backend diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f5d03fe2ac..37c94920f3 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -141,6 +141,7 @@ export abstract class CompactService extends Service { * prior replace can leave the surface non-monotonic in seq order), or if * either boundary is not a balanced tool-pairing cut (would split a step's * tool-call/result pair). + * @returns what the compaction did (the replaced range and its summary node). */ abstract compactRegion( session: Session, diff --git a/packages/core/README.md b/packages/core/README.md index a3e93777ab..eee8e3eed0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,7 +6,7 @@ The packages every harness build is assembled from: the session log, the system- |---|---|---| | `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` | +| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `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) | diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 28a3592ac6..353e68332d 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -13,11 +13,12 @@ This is the package to read to see **the whole plugin tree at once** — the tea @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-tools tool registry + tools/pre-execute/post-execute @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`) + (dsh-system-prompt gets the forwarded `persona`) ``` ## What it deliberately leaves OUTSIDE the bundle @@ -34,10 +35,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// Config === AgentLoop.Config — the `agents` list, default []. +// { agents?, persona? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]), +// so validation and defaulting can never drift from the owners'. ``` -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. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — and `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index a70ee30e71..5b1eed413a 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -44,5 +44,8 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" } } diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index ad3f5d8c46..0831ae929e 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -44,9 +44,10 @@ import type { Context } from 'cordis' import Timer from '@cordisjs/plugin-timer' +import z from 'schemastery' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { type Config as SystemPromptConfig } 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' @@ -56,33 +57,45 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen 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. + * Bundle config: each field forwarded verbatim to the child that owns it — + * `agents` to the agent loop (an app that pre-creates no agents, like the ACP + * bridge, simply omits it), `persona` to the system-prompt plugin (the + * deployment's persona section). Both are optional INPUT here because each + * owner's schema supplies the default (`[]` / `''`); the schema is the + * INTERSECTION of the owners' own schemas, so validation and defaulting can + * never drift from them. */ -export type Config = AgentLoopConfig +export interface Config { + /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ + agents?: AgentLoopConfig['agents'] + /** The deployment persona (see dsh-system-prompt's `Config`). */ + persona?: SystemPromptConfig['persona'] +} -/** Forward the loop's own schema so validation + defaulting stay identical. */ -export const Config = AgentLoop.Config +/** Intersect the owners' schemas so validation + defaulting stay identical. */ +export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config]) as unknown as z<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. + * `agent-loop` receives the forwarded `agents` list and `system-prompt` the + * forwarded `persona`. 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) + // The forwarded fields are validated + defaulted by this bundle's intersected + // schema before apply runs, so the ?? fallbacks only narrow the + // optional-input TYPES — they mirror the owners' schema defaults, never + // introduce different ones. + ctx.plugin(SystemPrompt, { persona: config.persona ?? '' }) ctx.plugin(ToolRegistry) ctx.plugin(AgentRegistry) ctx.plugin(invariants) ctx.plugin(toolBash) - ctx.plugin(AgentLoop, { agents: config.agents }) + 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 index 67f5d88532..4a4c5587ed 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -43,11 +43,27 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) - it('forwards a pre-created agent to the loop', async () => { + it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }], + agents: [{ id: AgentId('main'), model: 'mock' }], + persona: 'You are main.', }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const assembly = await ctx.get('systemPrompt')!.assemble() + expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') + await ctx.fiber.dispose() + }) + + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { + // ctx.plugin validates + defaults the bundle config first; a direct apply + // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. + const ctx = new Context() + agentCore.apply(ctx, {}) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('agents')?.list()).toHaveLength(0) + const assembly = await ctx.get('systemPrompt')!.assemble() + expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('') await ctx.fiber.dispose() }) diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json index 83bf06c586..91e5ec894e 100644 --- a/packages/core/agent-core/tsconfig.json +++ b/packages/core/agent-core/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../../vendor/timer" }, diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 5932bc6741..e169fce45a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -28,12 +28,11 @@ interface Config { agents: Array<{ id: string // required model?: string - systemPrompt?: string }> } ``` -Agents listed in config are auto-created at startup. +Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Classes @@ -45,23 +44,31 @@ Agents listed in config are auto-created at startup. One invocation of `runLoop()` drives one agent for its whole lifetime: ``` +create agent → emit agent/session-start(source) ⟵ once, before turn 1 forever: wait for queued messages (idle) TURN (error-contained): - drain queued → 'turn/start' → session('user/message') + 'turn/start' + each queued: waterfall agent/prompt-submit → allow (→ session('user/message'), + inject additionalContext) | block (→ session('prompt/blocked'), drop) + if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn STEP loop: drain steering - assembly = systemPrompt.assemble() + assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt await serial agent/pre-step ⟵ surface mutation (compaction) outside the step session('step/start') request = waterfall agent/request stream llm.stream(request) → session('assistant/chunk') message = waterfall agent/step-result session('assistant/message') - each tool-call: session('tool/call') → tools.execute() → session('tool/result') + each tool-call: session('tool/call') + → tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute] + → session('tool/result') + append buffered post-execute additionalContext as session('context/message')(s) drain steering → session('steering/message') - cont = waterfall agent/turn-continuation - if !cont: break + cont = waterfall agent/turn-continuation → ContinuationDecision + ({action:'continue', reason?} records reason as next-step steering) + if action==stop (and no pending steering): break session('turn/end') await session/flush re-enqueue leftover steering as queued @@ -75,9 +82,9 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears ### What is NOT here Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: -- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation` +- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` - Compaction: `agent/pre-step` -- Sandbox, permission, plan mode: `tools/execute` +- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute` - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred. - Persistence: `session/event` + `session/flush` -- UI: `agent/stream-chunk` + `agent/*` events +- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index d74c129ddd..433c28326b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -79,7 +79,7 @@ export class ReactLoopAgent implements Agent { // Release quiescence waiters on a transition OUT of running BEFORE emitting // (the disposer handles the disposed transition separately). Settling first // means a throwing `agent/status` subscriber cannot starve a `whenIdle()` - // waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must + // waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must // not hang on one bad listener). if (status !== 'running') this.settleIdleWaiters() try { diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ab95ea5aac..9eaa61624a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,7 +10,7 @@ import { Context, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' -import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } 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' @@ -72,7 +72,6 @@ export class AgentLoop extends Service implements AgentFactory { agents: z.array(z.object({ id: z.string().required(), model: z.string(), - systemPrompt: z.string(), resumeSessionId: z.string(), })).default([]), }) as unknown as z<Config> @@ -82,6 +81,16 @@ export class AgentLoop extends Service implements AgentFactory { // Provide the agent-creation factory to the registry (effect-scoped: the // slot is cleared on dispose). ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()') + // The prompt variables the shipped loop provides, registered once. The + // sections themselves (`harness:identity`, `deployment:persona`) belong to + // dsh-system-prompt — they must survive a swapped loop plugin — but + // `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives: + // it assembles with `{ agent }` each step (loop.ts), and the variables + // project the agent's configured model and its session workspace from that + // context. A provider returns undefined when the fact is absent + // (renderPrompt then rejects a persona that claims it — fail loud). + ctx.systemPrompt.variable('model', context => context.agent?.options.model) + ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) for (const { id, resumeSessionId, ...options } of config.agents) { if (resumeSessionId !== undefined && resumeSessionId !== '') { // Resume a prior session instead of starting fresh. resume() needs @@ -121,6 +130,9 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. + * @param id - the agent id; also seeds the generated session id. + * @param options - loop options (model, limits, …); defaults applied per option. + * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) @@ -129,7 +141,7 @@ export class AgentLoop extends Service implements AgentFactory { // 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(SessionId(`${id}-session-${randomUUID()}`), { meta: {} }) - const { agent } = this.start(id, options, session) + const { agent } = this.start(id, options, session, 'startup') return agent } @@ -142,6 +154,9 @@ export class AgentLoop extends Service implements AgentFactory { * `seed` (a balanced completed-turn prefix of the parent's log) so the child * starts with the parent's context. Returns an {@link AgentHandle} the owner * disposes to tear down exactly this agent. + * @param options - agent id, caller-supplied session id, optional seed/meta, + * and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a @@ -152,7 +167,9 @@ export class AgentLoop extends Service implements AgentFactory { ...options.seed !== undefined ? { seed: options.seed } : {}, meta: options.meta ?? {}, }) - return this.startOwned(options.agentId, options.agentOptions ?? {}, session) + // A seeded (forked) create is still a fresh start, NOT a resume — `resume` + // is reserved for reloading a PERSISTED session via resume()/resumeWith(). + return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup') } /** @@ -166,6 +183,8 @@ export class AgentLoop extends Service implements AgentFactory { * configured. NOT hard-injected (that would make non-persistent demos pend * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. + * @param options - the persisted session id to reload, plus agent id/options. + * @returns the handle for the agent resumed on the reconstructed session. */ async resume(options: ResumeAgentOptions): Promise<AgentHandle> { // Read the service through `ctx.get('sessionPersistence')` — a direct @@ -224,7 +243,7 @@ export class AgentLoop extends Service implements AgentFactory { ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, }, }) - return this.startOwned(options.agentId, options.agentOptions ?? {}, session) + return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume') } /** @@ -261,14 +280,33 @@ export class AgentLoop extends Service implements AgentFactory { * so a throwing `session/created`/`agent/created` listener unwinds the * already-yielded disposers instead of leaking. * + * `source` says why the session began ({@link SessionStartSource}); it is + * emitted as `agent/session-start` once, AFTER the agent is registered (so a + * listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into + * it) and BEFORE the loop starts its first turn. The emit is contained: a + * throwing session-start listener must not abort agent construction — it is + * logged, and the agent still starts. (Unlike a turn-boundary throw, there is + * no open turn here to balance; the durable evidence of a session-start hook + * is whatever it `inject()`ed.) + * * Returns the agent plus the composite effect's disposer (`disposeAgent`). */ - private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } { + private start( + id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + ): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } { 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) + // Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and + // BEFORE the loop's first turn. Contained: a throwing listener is logged, + // never aborts construction (no open turn to balance here). + try { + this.ctx.emit('agent/session-start', agent, source) + } catch (error: unknown) { + this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`) + } 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, @@ -295,8 +333,8 @@ export class AgentLoop extends Service implements AgentFactory { * `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()` * helper). */ - private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle { - const { agent, disposeAgent } = this.start(id, options, session) + private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle { + const { agent, disposeAgent } = this.start(id, options, session, source) let disposing: Promise<void> | undefined return { agent, dispose: () => (disposing ??= disposeAgent()) } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 200115af9a..0b84bcb83f 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,6 +10,7 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' @@ -142,30 +143,38 @@ export interface LoopHandle { * The agent loop. One invocation drives one agent for its whole lifetime: * * ``` + * create agent → emit agent/session-start(source) ⟵ once, before turn 1 * forever: * wait for queued messages (idle) * TURN (error-contained — a throwing plugin ends the turn, never the loop): - * drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start + * 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror) + * allow → session('user/message'…) (+ inject additionalContext) | block → drop + * every prompt blocked → 'turn/end'(rejected), 0 steps * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble + * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt + * (persona section + {{variables}}) IS the full prompt * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step - * session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC) + * session('step/start') ⟵ durable step boundary (no agent/* mirror) * req = {model, system, tools, messages: session.deriveMessages(), signal} * req = waterfall agent/request ⟵ hooks/model-switch * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks) - * session('assistant/chunk'); emit agent/stream-chunk + * session('assistant/chunk') * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the * 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/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask) + * → dispatch → tools/post-execute * session('tool/result') - * drain steering → session('steering/message'); emit agent/steering - * emit agent/step-end - * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) - * if !cont && steering arrived from step-end/continuation listeners: cont = true - * if !cont: break - * session('turn/end'); emit agent/turn-end + * append buffered post-execute additionalContext → session('context/message')(s) + * drain steering → session('steering/message') + * session('step/end') ⟵ durable step boundary (no agent/* mirror) + * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default + * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is + * recorded as next-step steering + * if action==stop && steering arrived (step/end/continuation listeners): continue anyway + * if action==stop: break + * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) * await ctx.parallel('session/flush', session) ⟵ durability checkpoint * re-enqueue leftover steering as queued ⟵ steering is never stranded * idle (emit agent/status) unless more queued @@ -277,37 +286,32 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let reason: TurnEndReason = { kind: 'completed' } let step = 0 - let turnEnded = false let stepOpen = false let errorReported = false - // Close the open step exactly once (idempotent via stepOpen). The - // agent/step-end emit is contained: a throwing step-end listener must not - // abort finalization and strand the turn open (turn/end balance > notifying - // one bad listener). Appended before the emit (the event-sourcing RFC append-before-emit). + // Close the open step exactly once (idempotent via stepOpen). Step boundaries + // are durable session events only — there is no agent/* step emit to mirror + // them (see the agent event-domain rule). A throwing step/end session-event + // listener must not abort finalization and strand the turn open (turn/end + // balance > notifying one bad listener); it is contained and surfaced as a + // turn error below. const closeStep = (): boolean => { if (!stepOpen) return false stepOpen = false // Session.append pushes step/end BEFORE notifying session/event listeners, // so a throwing listener leaves step/end in the log (balance holds) but // would otherwise abort finalization. Contain it and surface it as a turn - // error below — the same outcome as a throwing agent/step-end listener. + // error below. let failure: unknown try { session.append('step/end', { turn, step }) } catch (error: unknown) { failure = error } - try { - ctx.emit('agent/step-end', agent, turn, step) - } catch (error: unknown) { - failure ??= error - } - // A throwing step/end session-event listener OR a throwing agent/step-end - // listener surfaces as a turn error via failTurn (idempotent). This prevents - // a throwing listener from producing a silent "completed" turn when the step - // itself succeeded, AND keeps finalization going when closeStep runs from - // the outer catch. + // A throwing step/end session-event listener surfaces as a turn error via + // failTurn (idempotent). This prevents a throwing listener from producing a + // silent "completed" turn when the step itself succeeded, AND keeps + // finalization going when closeStep runs from the outer catch. if (failure !== undefined) { failTurn(toError(failure)) return true @@ -324,47 +328,38 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // 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}`) - } + // The turn is always still open here: the only failure that can reach + // failTurn once turn/end is appended would be a throwing turn-boundary + // listener, and turn boundaries are durable session events with no agent/* + // mirror to throw. A throwing `turn/end` session-event listener is already + // contained inside closeTurn (append pushes before notifying, so the + // boundary is durable). So set the error reason for closeTurn to append. + reason = { kind: 'error', step, ...errorData(err) } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // 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. + // contained: the error is already captured on `reason`; a throwing + // agent/error listener must not prevent the turn from closing. } } - // Close the turn exactly once (idempotent via turnEnded). `emit` is false on - // the error path (the failure was already surfaced via agent/error) and true - // on the normal/inline-error path. A throwing agent/turn-end listener on the - // normal path escapes to the outer catch, which surfaces it via failTurn — - // turn/end is already appended, so balance holds either way. - const closeTurn = (emit: boolean): void => { - if (turnEnded) return - turnEnded = true + // Close the turn. Called exactly once per turn — the normal loop exit and the + // outer catch are mutually exclusive paths, and this never throws (the append + // is contained below), so there is no re-entry to guard against (unlike + // closeStep, which the cancel branches and the outer catch can both reach). + // Turn boundaries are durable session events only — there is no agent/* turn + // emit to mirror them (see the agent event-domain rule). + const closeTurn = (): void => { // Session.append pushes turn/end BEFORE notifying session/event listeners, // so a throwing listener leaves turn/end in the log (the turn is balanced) - // but would otherwise escape — from the outer catch's closeTurn(false) it - // would propagate to the runLoop backstop, and from the normal-path - // closeTurn(true) it would skip the agent/turn-end emit. Contain it: the - // boundary is durable either way, and finalization must not abort on a bad - // listener. (On the normal path the outer catch also re-runs closeTurn, - // which is an idempotent no-op once turnEnded is set.) + // but would otherwise escape — from the outer catch it would propagate to + // the runLoop backstop. Contain it: the boundary is durable either way, and + // finalization must not abort on a bad listener. try { session.append('turn/end', { turn, reason }) } catch (error: unknown) { ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`) } - if (emit) ctx.emit('agent/turn-end', agent, turn, reason) } try { @@ -373,20 +368,60 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // decides "owed" from the log via isTurnOpen, so even a throwing turn/start // listener — append pushes before notifying — still gets its turn/end). session.append('turn/start', { turn, trigger }) - // Record the queued user messages INSIDE the turn (after turn/start), so - // every event in the log is turn-enclosed. turn/end is now owed, so a throw - // while appending these is caught below and the turn is still closed. + // Each drained queued message runs the `agent/prompt-submit` waterfall before + // it becomes a `user/message` — a hook can rewrite the prompt or block it. + // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; + // turn/end is now owed, so a throwing prompt-submit listener (the waterfall + // throws) is caught below and the turn still closes. + let anyAllowed = false + // Seeded with a floor (only observable if the batch were empty, which + // runTurn never allows — it is called with ≥1 queued message); each `block` + // decision carries a required `reason` and overwrites it, so a fully-blocked + // batch always reports the last vetoing reason. + let lastBlockReason = 'prompt blocked by hook' for (const message of queued) { - session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' }) + const decision = await ctx.waterfall( + 'agent/prompt-submit', agent, message.content, message.source, + () => Promise.resolve<PromptDecision>({ kind: 'allow' }), + ) + if (decision.kind === 'block') { + lastBlockReason = decision.reason + // Record the veto durably: `PromptDecision.reason` is the durable record + // of why a prompt was blocked, but a fully-blocked batch's `rejected` + // turn/end only preserves the LAST reason, and a MIXED batch (this prompt + // blocked, another allowed) does not end `rejected` at all — so without + // this append a blocked prompt would vanish from the log whenever any + // sibling prompt is allowed. `prompt/blocked` sits in the open turn in + // place of the `user/message` this prompt would have become. + session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason }) + continue + } + anyAllowed = true + // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. + const content = decision.content ?? message.content + session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) + // `allow.additionalContext` is a SEPARATE context/message the next request + // also sees. The turn is open, so inject() appends it into THIS turn. + if (decision.additionalContext) { + agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source }) + } } - ctx.emit('agent/turn-start', agent, turn) while (true) { + // A fully-blocked batch (every prompt vetoed by prompt-submit) opens a + // zero-step turn that ends `rejected`: break BEFORE the first step so the + // boundary stays balanced (turn/start → turn/end) and the block is a + // durable in-turn fact. `anyAllowed` never changes inside the loop, so this + // only ever fires on the first iteration. + if (!anyAllowed) { + reason = { kind: 'rejected', reason: lastBlockReason } + break + } step += 1 - // Steering from the previous round's step-end/continuation listeners - // (or turn-start listeners on the first step) joins before the request. - drainSteering(ctx, agent, turn) + // Steering from the previous round's continuation listeners joins before + // the request. + drainSteering(agent, turn) // The step's AbortController exists BEFORE any async pre-step work so a // dispose() or cancel() — in a synchronous turn-start listener or an @@ -400,11 +435,11 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // because the pre-step seam needs it: compaction measures token pressure // against the system prompt (it counts toward the budget). runStep reuses // this same assembly for the request, so the prompt is assembled once per - // step. - const assembly = await ctx.systemPrompt.assemble() - const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? ''] - .filter(text => text.length > 0) - .join('\n\n') + // step. renderPrompt IS the full prompt — the persona is the order-0 + // section (registered by the AgentLoop plugin) and `{{variable}}` + // interpolation happens in the render, so there is no separate join. + const assembly = await ctx.systemPrompt.assemble({ agent }) + const fullSystemPrompt = renderPrompt(assembly) // Interruption landing after assembly: dispose() or cancel() in a // turn-start listener (or a listener whose promise resolved before the @@ -432,24 +467,25 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // pre-step plugin ends the turn, not the loop. await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal) - // Interruption landing during the pre-step seam: do not open an empty - // step. `agent/step-start` listeners get their own check below because - // they necessarily run after step/start is appended/emitted. + // Interruption landing during the pre-step seam: do not open an empty step. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } - session.append('step/start', { turn, step }) + // Mark the step open BEFORE the append: Session.append pushes the event + // to the log before notifying session/event listeners, so a THROWING + // step/start listener leaves step/start in the log. Setting stepOpen first + // means the outer catch's closeStep() then appends the balancing step/end + // (turn stays enclosed) instead of stranding an open step under turn/end. stepOpen = true - ctx.emit('agent/step-start', agent, turn, step) + session.append('step/start', { turn, step }) - // Cancel landing in the step-start window: a synchronous - // `agent/step-start` listener can cancel after the step is already open. - // Check AFTER step/start append + emit and before `runStep`: drop the - // step, end the turn accordingly. closeStep balances the already-appended - // step/start. + // Cancel landing in the step-start window: a synchronous `session/event` + // step/start listener can cancel after the step is already open. Check + // AFTER the step/start append and before `runStep`: drop the step, end the + // turn accordingly. closeStep balances the already-appended step/start. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } @@ -494,14 +530,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, if (stepReason) reason = stepReason // Steering that arrived during streaming/tool execution. - const steered = drainSteering(ctx, agent, turn) + const steered = drainSteering(agent, turn) if (closeStep()) break - const defaultDecision = stepOutcome.hadToolCalls || steered - let shouldContinue: boolean + const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } + let decision: ContinuationDecision try { - shouldContinue = await ctx.waterfall( + decision = await ctx.waterfall( 'agent/turn-continuation', agent, turn, defaultDecision, () => Promise.resolve(defaultDecision), ) @@ -511,9 +547,18 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, break } - // Steering from step-end/continuation listeners (the /goal pattern) - // demands the model see it — it overrides a negative decision; the - // next iteration's drain records it. + // A forced `continue` may carry model-facing context: record it as + // next-STEP steering (the steering channel), so the continued turn's next + // iteration drains it before its request — the typed twin of the /goal + // step/end-steer pattern. + if (decision.action === 'continue' && decision.reason) { + agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) + } + let shouldContinue = decision.action === 'continue' + + // Steering from step/end session-event or continuation listeners (the + // /goal pattern) demands the model see it — it overrides a stop decision; + // the 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 @@ -533,8 +578,8 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } } - // Normal / inline-error loop exit: close the turn and notify. - closeTurn(true) + // Normal / inline-error loop exit: close the turn. + closeTurn() } catch (error: unknown) { // Decide whether this turn was ever opened from the LOG, not a flag. // Session.append pushes the event BEFORE notifying session/event listeners, @@ -543,28 +588,29 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // Gating on a "turn started" boolean would skip turn/end and leave a // permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We // check the log for THIS turn's turn/start: present means a turn/end is owed - // (or was already appended — closeTurn/failTurn are idempotent, so running - // them again is a safe no-op that still preserves the disposed/error reason - // chosen below). Absent means the turn/start append threw BEFORE its push (a - // non-serializable trigger — impossible for our fixed trigger); nothing was - // opened, so rethrow to the runLoop backstop. + // and the normal-exit `closeTurn()` did NOT run (we are here because a throw + // preceded it — the two `closeTurn()` sites are on mutually exclusive paths), + // so this catch appends turn/end with the disposed/error reason chosen below. + // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run + // already in a step branch, so running it again is a safe no-op. Absent + // turn/start means the append threw BEFORE its push (a non-serializable + // trigger — impossible for our fixed trigger); nothing was opened, so rethrow + // to the runLoop backstop. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() // Choose the close reason. Disposal wins only if no error was already // reported: a turn disposed mid-step sets reason=disposed in the step-error - // branch (without reporting an error), and if closeTurn(true)'s turn-end - // emit then throws, we land here and must PRESERVE disposed rather than - // overwrite it with the listener's throw. Otherwise a boundary-emit throw - // on a live agent is a real failure → failTurn. (errorReported is mutated - // only inside the failTurn closure, which the analyzer can't follow, hence - // the inline lint-disable.) + // branch (without reporting an error), so preserve disposed rather than + // overwrite it. Otherwise a mid-step throw on a live agent is a real + // failure → failTurn. (errorReported is mutated only inside the failTurn + // closure, which the analyzer can't follow, hence the inline lint-disable.) if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition reason = { kind: 'disposed' } } else { failTurn(toError(error)) } - closeTurn(false) + closeTurn() } // Durability checkpoint: persistence plugins drain write-behind buffers. @@ -590,11 +636,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } /** Drain the steering queue into the session. Returns whether any arrived. */ -function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean { +function drainSteering(agent: ReactLoopAgent, turn: number): boolean { const messages = agent.inbox.drainSteering() for (const message of messages) { agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) - ctx.emit('agent/steering', agent, turn, message.content, message.source) } return messages.length > 0 } @@ -636,7 +681,6 @@ async function runStep( if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) chunkSeqs.push(chunkEvent.seq) - ctx.emit('agent/stream-chunk', agent, turn, step, chunk) assembler.push(chunk) } @@ -695,6 +739,12 @@ async function runStep( // ToolRegistry.execute converts tool failures (including aborts) into // isError results, so abort is re-checked around every call here. const toolCalls = message.content.filter(block => block.type === 'tool-call') + // Per-step buffer of `additionalContext` attached by tools/post-execute + // listeners. Appended as context/message(s) only AFTER every tool/result for + // the step, so a multi-call step keeps tool-call/result adjacency + // (interleaving context between a call's result and the next call's would + // break the pairing the next model request relies on). + const pendingContext: HookContext[] = [] for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) @@ -705,6 +755,12 @@ async function runStep( } catch { parsedArguments = call.arguments } + // TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite + // `arguments` — tool/call (the audit record) and assistant/message (the + // model-history source) are logged BEFORE execute, and live consumers (ACP, + // tool-bash presentation) read the pre-execution args, so an execution-only + // rewrite would desync the UI from what ran. Designing that consistently is + // its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md). const result = await ctx.tools.execute({ callId: call.id, name: call.name, @@ -716,7 +772,7 @@ async function runStep( turn, step, // The correlation id MUST be the loop's authoritative call.id (the // model-transcript id that deriveMessages turns into toolCallId), NOT - // result.callId — a tools/execute waterfall listener returning a + // result.callId — a post-execute waterfall listener returning a // mismatched id would otherwise orphan the call↔result pairing in the // next model request. A listener-internal id, if ever needed, belongs in // a separate diagnostic field, never overloaded onto callId. @@ -724,7 +780,12 @@ async function runStep( content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, + // The tool's private presentation payload (e.g. a result-time diff), + // persisted so a UI bridge reproduces the card on replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) + // Buffer (don't append yet) any post-execute additionalContext for this call. + if (result.additionalContext) pendingContext.push(result.additionalContext) // 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: cancel()/disposal always set it */ @@ -733,6 +794,13 @@ async function runStep( /* v8 ignore stop */ } + // Append buffered post-execute context AFTER every tool/result, preserving + // tool-call/result adjacency across the whole batch. inject() appends into the + // open turn (a context/message at its chronological position). + for (const context of pendingContext) { + agent.inject(context.content, { source: context.source }) + } + return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 32c46f72f0..0e77f0bcbf 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -117,7 +117,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -134,7 +134,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -166,22 +166,23 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) - it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => { + it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // 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 the step AbortController, is what catches this) — no model step runs. + // A turn/start listener fires right after turn/start is appended, BEFORE any + // AbortController is installed for the step. Cancelling there must still drop + // the step (the turn-scoped marker, 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) => { - if (subject === agent) agent.cancel('from turn-start') + ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start') }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -194,23 +195,23 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }]) }) - it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => { + it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A step-start listener fires AFTER step/start is appended (and after the - // pre-step seam), so cancelling there lands in the SECOND cancel check (the - // one that must closeStep() to balance the already-open step) — distinct - // from a turn-start cancel, which is caught before the step opens. + // A step/start session-event listener fires AFTER step/start is appended + // (and after the pre-step seam), so cancelling there lands in the SECOND + // cancel check (the one that must closeStep() to balance the already-open + // step) — distinct from a turn-start cancel, caught before the step opens. let streamed = false - ctx.on('agent/stream-chunk', () => { streamed = true }) - const dispose = ctx.on('agent/step-start', (subject) => { - if (subject === agent) agent.cancel('from step-start') + ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start') }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -224,7 +225,7 @@ describe('Agent.cancel()', () => { expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) - it('disposal from a synchronous agent/step-start listener closes the open step as disposed', async () => { + it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = new Context() await ctx.plugin(LlmService) @@ -244,9 +245,9 @@ describe('Agent.cancel()', () => { let disposalDone: Promise<void> | undefined let streamed = false - ctx.on('agent/stream-chunk', () => { streamed = true }) - ctx.on('agent/step-start', (subject) => { - if (subject === agent) disposalDone = handle.dispose() + ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose() }) send(agent, 'go') @@ -271,16 +272,18 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('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)) + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/start') steps += 1 + if (event.type === 'turn/end') reasons.push(event.data.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 { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override } return next() }) @@ -305,7 +308,7 @@ describe('Agent.cancel()', () => { // 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 }) + ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'running') agent.cancel('from running listener') }) 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 8cf5bd81f8..a645fb3553 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -35,7 +35,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent @@ -52,7 +52,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent @@ -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: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -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: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 3eefbf6986..7a044bfb57 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) { } describe('turn boundary listener throws (handled in-turn, loop survives)', () => { - it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => { - // The agent/turn-start emit happens AFTER turn/start is appended to the log, - // so a throwing listener is handled inside runTurn (the turn is balanced and - // closed via failTurn → agent/error), NOT rethrown to the runLoop backstop. - // 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(AgentId('a1'), { model: 'mock' }) - - let threwOnce = false - ctx.on('agent/turn-start', () => { - if (!threwOnce) { - threwOnce = true - throw new Error('broken turn-start listener') - } - }) - - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - - send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['broken turn-start listener']) - // The turn is balanced: its turn/start was logged, so a turn/end was owed - // and appended (decided from the log, not a flag). - expect(agent.session.events.at(-1)?.type).toBe('turn/end') - - // loop survives: second turn works fine and makes the model call - send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) - expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true) - }) - - 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(AgentId('a1'), { model: 'mock' }) - - let threwOnce = false - ctx.on('agent/turn-end', () => { - if (!threwOnce) { - threwOnce = true - throw new Error('broken turn-end listener') - } - }) - - const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - - send(agent, 'first') - await waitForIdle(ctx, agent) - // The turn-end throw happens after the model call is complete, so turn 1's - // request is consumed. turn/end is already in the log (append pushes before - // notifying), so the turn is balanced; the error is surfaced via agent/error. - expect(errors.map(e => e.message)).toEqual(['broken turn-end listener']) - - // loop survives: second turn works fine - send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) - }) - it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => { // A non-serializable message source makes the turn/start append throw BEFORE // the event is pushed (Session.append validates before push), so turn/start @@ -192,14 +129,14 @@ describe('tool JSON parse', () => { }) describe('toError normalization', () => { - it('normalizes non-Error throws from turn-start listeners via toError', async () => { + it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/turn-start', () => { - if (!threwOnce) { + ctx.on('session/event', (_session, event) => { + if (event.type === 'turn/start' && !threwOnce) { threwOnce = true throw 'naked string error' // non-Error throw, normalized via toError } @@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts new file mode 100644 index 0000000000..e76ac30fa9 --- /dev/null +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -0,0 +1,529 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { + AgentId, + type ContinuationDecision, + type PromptDecision, + type SessionStartSource, +} from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +/** + * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`, + * `agent/session-start`, the reshaped `agent/turn-continuation` + * ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute` + * split with `additionalContext` buffering. These verify the canonical event + * surface a hook bridge (or a native plugin) programs against, WITHOUT any + * external protocol — a native plugin uses the typed decisions directly. + */ + +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 waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function send(agent: ReactLoopAgent, text: string) { + agent.send([{ type: 'text', text }]) +} + +function events(agent: ReactLoopAgent): SessionEvent[] { + return [...agent.session.events] +} + +describe('agent/prompt-submit', () => { + it('allow (default via next) records the user/message unchanged', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const seen: string[] = [] + ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { + seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join('')) + return next() + }) + + send(agent, 'hello') + await waitForIdle(ctx, agent) + + expect(seen).toEqual(['hello']) + const userMsg = events(agent).find(e => e.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('allow with content REWRITES the prompt before it is recorded', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => + ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) + + send(agent, 'original') + await waitForIdle(ctx, agent) + + const userMsg = events(agent).find(e => e.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }]) + // the rewritten prompt is what reached the model + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN') + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') + }) + + it('allow with additionalContext injects a separate context/message into the turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => + ({ + kind: 'allow', + additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } }, + })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const log = events(agent) + const userMsg = log.find(e => e.type === 'user/message') + const ctxMsg = log.find(e => e.type === 'context/message') + expect(userMsg).toBeDefined() + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + // both the prompt and the injected context reach the model + const sent = JSON.stringify(adapter.requests[0]!.messages) + expect(sent).toContain('extra ctx') + }) + + it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { + // The merge of the interception seams with master's compaction seam pins one + // ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting + // context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step + // before the single deriveMessages(). So a compaction listener on + // `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject — + // otherwise it would measure/compact stale history. This cross-test proves + // the two seams compose in the right order (each is covered in isolation + // elsewhere; this asserts they see each other's effects on the same turn). + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => + ({ + kind: 'allow', + content: [{ type: 'text', text: 'REWRITTEN prompt' }], + additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }, + })) + + // The pre-step seam (where compaction lives) derives the surface it would act + // on. Capture what it sees on the first step. + let preStepDerived: string | undefined + ctx.on('agent/pre-step', (subject, _turn, step) => { + if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) + }) + + send(agent, 'ORIGINAL prompt') + await waitForIdle(ctx, agent) + + // The pre-step seam ran and saw BOTH the rewrite (not the original) and the + // injected context — i.e. the prompt-submit effects landed before it. + expect(preStepDerived).toBeDefined() + expect(preStepDerived).toContain('REWRITTEN prompt') + expect(preStepDerived).toContain('injected ctx') + expect(preStepDerived).not.toContain('ORIGINAL prompt') + }) + + it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => + ({ kind: 'block', reason: 'blocked by policy' })) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + send(agent, 'do something') + await waitForIdle(ctx, agent) + + // the model was never called + expect(adapter.requests).toHaveLength(0) + // the turn opened and closed balanced, with no user/message and no step + const log = events(agent) + expect(log.some(e => e.type === 'turn/start')).toBe(true) + expect(log.some(e => e.type === 'turn/end')).toBe(true) + expect(log.some(e => e.type === 'user/message')).toBe(false) + expect(log.some(e => e.type === 'step/start')).toBe(false) + // the veto is recorded durably as a prompt/blocked in the open turn + const blocked = log.find(e => e.type === 'prompt/blocked') + expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({ + content: [{ type: 'text', text: 'do something' }], + reason: 'blocked by policy', + }) + // ended rejected with the block reason + expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }]) + const turnEnd = log.findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) + }) + + it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { + // Two prompts queued into ONE turn: block "secret", allow "safe". The turn is + // NOT rejected (a prompt was allowed), so without a durable prompt/blocked the + // vetoed prompt and its reason would vanish from the log entirely. + const adapter = new MockAdapter([textResponse('ran once')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => { + const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') + return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next() + }) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + // both sends land before the loop drains → one batched turn + send(agent, 'secret') + send(agent, 'safe') + await waitForIdle(ctx, agent) + + const log = events(agent) + // the allowed prompt became a user/message and drove exactly one model call + const userMsgs = log.filter(e => e.type === 'user/message') + expect(userMsgs).toHaveLength(1) + expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) + expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + // the blocked prompt is durably recorded, with its content + reason + const blocked = log.filter(e => e.type === 'prompt/blocked') + expect(blocked).toHaveLength(1) + expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({ + content: [{ type: 'text', text: 'secret' }], + reason: 'policy: no secrets', + }) + // the turn did NOT reject — a sibling was allowed — so the boundary reason + // alone would not have preserved the block + expect(reasons.some(r => r.kind === 'rejected')).toBe(false) + }) + + it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { + const adapter = new MockAdapter([textResponse('after')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let threw = false + ctx.on('agent/prompt-submit', async () => { + if (!threw) { threw = true; throw new Error('prompt hook broke') } + return { kind: 'allow' as const } + }) + const errors: Error[] = [] + ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + + send(agent, 'first') + await waitForIdle(ctx, agent) + expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) + // turn balanced + const log = events(agent) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) + + // loop survives: a second prompt runs normally + send(agent, 'second') + await waitForIdle(ctx, agent) + expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + }) +}) + +describe('agent/session-start', () => { + it('fires once with source "startup" for a fresh create, before the first turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + + const sources: SessionStartSource[] = [] + ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) + + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // fires synchronously at create, before any turn + expect(sources).toEqual(['startup']) + expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) + + send(agent, 'go') + await waitForIdle(ctx, agent) + // still only one session-start + expect(sources).toEqual(['startup']) + }) + + it('a session-start listener can inject context the first request sees', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + + ctx.on('agent/session-start', (agent) => { + agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) + }) + + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + send(agent, 'go') + await waitForIdle(ctx, agent) + + // the injected context reached the model on the first (only) request + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') + // and is recorded with the plugin source, never mislabeled as a user prompt + const ctxMsg = events(agent).find(e => e.type === 'context/message') + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + }) + + it('a throwing session-start listener does not abort agent construction', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + + ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) + + // create must not throw — the listener error is contained/logged + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + expect(agent.id).toBe(AgentId('a1')) + + // and the agent still runs + send(agent, 'go') + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + }) +}) + +describe('agent/turn-continuation (ContinuationDecision)', () => { + it('a continue decision with a reason records next-step steering in the same turn', async () => { + const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let forced = false + ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => { + if (!forced) { + forced = true + return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } } + } + return next() + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const log = events(agent) + // same turn, two steps + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(log.filter(e => e.type === 'step/start')).toHaveLength(2) + // the reason was recorded as steering BEFORE step 2, with its plugin source + const steering = log.find(e => e.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }]) + expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' }) + // and reached the next request + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal') + }) + + it('a stop decision ends the turn even when the step had tool calls', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // default would have continued (had tool calls), but the stop decision wins + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'tool/result')).toBe(true) + }) +}) + +describe('tools/post-execute additionalContext buffering across a multi-call step', () => { + it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => { + // One assistant step with TWO tool calls; the second model response stops. + const twoCalls = [ + { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const }, + { type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } }, + { type: 'block-start' as const, index: 1, blockType: 'tool-call' as const }, + { type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } }, + { type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } }, + { type: 'finish' as const, reason: { kind: 'tool-calls' as const } }, + ] + const adapter = new MockAdapter([twoCalls, textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + // Each call attaches additionalContext naming itself. + ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> => + ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // Event order in the log: both tool/results, THEN both context/messages — + // never interleaved (which would break tool-call/result adjacency). + const types = events(agent).map(e => e.type) + const firstResult = types.indexOf('tool/result') + const lastResult = types.lastIndexOf('tool/result') + const firstCtx = types.indexOf('context/message') + expect(firstResult).toBeGreaterThanOrEqual(0) + expect(lastResult).toBeGreaterThan(firstResult) // two results + expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results + // both contexts present + const ctxTexts = events(agent) + .filter(e => e.type === 'context/message') + .flatMap(e => (e.type === 'context/message' ? e.data.content : [])) + .map(b => (b.type === 'text' ? b.text : '')) + expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) + }) +}) + +describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => { + it('deny short-circuits dispatch into an isError result the model sees', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')]) + const ctx = await harness(adapter) + let ran = false + ctx.tools.register(defineTool({ + name: 'danger', description: 'danger', parameters: {}, + async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { + if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } + return next() + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(ran).toBe(false) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' + && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true) + }) +}) + +describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => { + // The whole point of the interception taxonomy: a "native hook" needs no + // dsh-hook-protocol, no external command, no hook/* log — it is an ordinary + // cordis plugin subscribing to the canonical events and returning typed + // decisions. This proves all four seams compose end-to-end through the REAL + // loop, with NO hook/* SessionEvents involved (those belong to the bridge lib). + const NativeGuard = { + name: 'native-guard', + apply(ctx: Context) { + // 1. SessionStart: seed a standing instruction. + ctx.on('agent/session-start', (agent, source) => { + agent.inject( + [{ type: 'text', text: `policy active (started: ${source})` }], + { source: { kind: 'plugin', plugin: 'native-guard' } }, + ) + }) + // 2. PromptSubmit: block a forbidden prompt, annotate the rest. + ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => { + const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') + if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' } + return next() + }) + // 3. PreToolUse: deny a dangerous tool by name. + ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { + if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' } + return next() + }) + // 4. PostToolUse: attach context after a tool runs. + ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => { + const decision = await next() + if (decision.kind === 'accept') { + return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } } + } + return decision + }) + }, + } + + it('all four seams fire for a real allowed turn with a tool call', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')]) + const ctx = await harness(adapter) + await ctx.plugin(NativeGuard) + ctx.tools.register(defineTool({ + name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, + async execute(args) { return [{ type: 'text', text: String(args.text) }] }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'please echo hi') + await waitForIdle(ctx, agent) + + const log = events(agent) + // session-start preamble injected + expect(log.some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true) + // prompt allowed → user/message recorded + expect(log.some(e => e.type === 'user/message')).toBe(true) + // tool ran (echo allowed) and post-execute attached "audited" context + expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true) + expect(log.some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true) + // NO hook/* events — a native plugin needs none + expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) + }) + + it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + await ctx.plugin(NativeGuard) + const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + send(agent, 'run rm -rf /') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }]) + }) + + it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const fiber = await ctx.plugin(NativeGuard) + await fiber.dispose() + + // After disposal, a destructive prompt is NOT blocked (the listener is gone). + const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' }) + send(agent, 'run rm -rf /') + await waitForIdle(ctx, agent) + // the prompt ran (not rejected) — proving the prompt-submit listener was disposed + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'user/message')).toBe(true) + }) +}) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index aa565f15b5..16a9b5e394 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -8,11 +8,11 @@ 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' -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) @@ -46,15 +46,20 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // All boundaries — turn and step — are durable session events on the + // session/event feed (no agent/* mirror). Record them in fire order to + // assert the full boundary nesting. const order: string[] = [] - for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) { - ctx.on(name, () => void order.push(name)) - } + ctx.on('session/event', (_session, event) => { + if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') { + order.push(event.type) + } + }) send(agent, 'hi') await waitForIdle(ctx, agent) - expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end']) + expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) const types = agent.session.events.map(e => e.type) // turn/start opens the turn, THEN the queued user message is recorded inside @@ -110,10 +115,38 @@ describe('agent loop', () => { expect(types).toContain('tool/result') }) - it('passes assembled system prompt and tool schemas into the request', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), + textResponse('done'), + ]) const ctx = await harness(adapter) - ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' }) + // A tool that returns the { content, meta } object form: the loop must + // persist `meta` on the tool/result event so a UI reproduces the card on replay. + ctx.tools.register(defineTool({ + name: 'writer', + description: 'writes a file', + parameters: { path: { type: 'string' } }, + async execute() { + return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } + }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'use the tool') + await waitForIdle(ctx, agent) + + const toolResult = agent.session.events.find(e => e.type === 'tool/result') + expect(toolResult?.type === 'tool/result' && toolResult.data.meta) + .toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] }) + }) + + it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + // The persona is a TEMPLATE: {{model}} is the loop-registered variable + // projecting this agent's configured model, so the model knows its own name. + const ctx = await harness(adapter, 'You are a test agent on {{model}}.') + ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' }) ctx.tools.register(defineTool({ name: 'noop', description: 'does nothing', @@ -122,31 +155,122 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' }) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) const request = adapter.requests[0] - expect(request!.system).toBe('You are a test agent.\n\nAgent-specific suffix.') + expect(request!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a test agent on mock.\n\nUse the noop tool wisely.') expect(request!.tools?.map(t => t.name)).toEqual(['noop']) }) - it('records raw chunks for replay and emits agent/stream-chunk', async () => { + it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter, 'Working in {{cwd}}.') + const handle = ctx.agents.create({ + agentId: AgentId('a-cwd'), + sessionId: SessionId('s-cwd'), + meta: { cwd: '/work/space' }, + agentOptions: { model: 'mock' }, + }) + + const agent = handle.agent as ReactLoopAgent + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nWorking in /work/space.') + }) + + it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => { + // A persona claiming {{cwd}} on a session with NO cwd is a deployment + // authoring error — renderPrompt throws, the turn ends with an error, and + // the same agent must then RUN a later turn to completion (not merely + // report idle status): a rescue listener supplies the variable and the + // follow-up prompt reaches the model. + const adapter = new MockAdapter([textResponse('ok after rescue')]) + const ctx = await harness(adapter, 'In {{cwd}}.') + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) // the request was never sent + expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true) + const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') + + // The loop survived: a waterfall listener rescues {{cwd}} and the SAME + // agent completes a real model turn. + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.variables['cwd'] = '/rescued' + return next() + }) + send(agent, 'again') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nIn /rescued.') + const turnEnds = agent.session.events.filter(e => e.type === 'turn/end') + expect(turnEnds).toHaveLength(2) + expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') + }) + + it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => { + // AgentOptions.model unset: the model arrives in the agent/request + // waterfall (the loop's documented fallback — see runStep's no-model + // error). {{model}} renders BEFORE that waterfall, so the SAME plugin + // states the fact early on system-prompt/assemble — the owner of a + // late-bound fact owns stating it wherever it is claimed. + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter, 'You run on {{model}}.') + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.variables['model'] = 'mock' + return next() + }) + ctx.on('agent/request', async (_agent, _turn, _step, options, next) => { + options.model = 'mock' + return next() + }) + const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]!.model).toBe('mock') + expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.') + }) + + it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { + // The documented escape valve: a deployment that must drop the harness + // openers short-circuits the assemble waterfall; the request then carries + // NO system field at all (not an empty string). + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) + const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' }) + + send(agent, 'hi') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect('system' in adapter.requests[0]!).toBe(false) + }) + + it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const streamed: StreamChunk[] = [] - ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk)) - send(agent, 'hi') await waitForIdle(ctx, agent) const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk') // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 expect(chunkEvents).toHaveLength(7) - expect(streamed).toHaveLength(7) // replay: chunk events alone re-assemble to the recorded assistant message const deltaText = chunkEvents .flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : []) @@ -269,9 +393,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('agent/step-end', () => void steps++) + ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => { - if (steps < 3) return true + if (steps < 3) return { action: 'continue' as const } return next() }) @@ -294,7 +418,7 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/turn-continuation', async () => false as const) + ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const) send(agent, 'go') await waitForIdle(ctx, agent) @@ -344,10 +468,12 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // One fire per step, in order, each with the assembled system prompt. + // One fire per step, in order, each with the assembled system prompt + // (here just the loop's own harness-identity section — no persona set). + const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.' expect(fires).toEqual([ - { turn: 1, step: 1, fullSystemPrompt: '' }, - { turn: 1, step: 2, fullSystemPrompt: '' }, + { turn: 1, step: 1, fullSystemPrompt: HARNESS }, + { turn: 1, step: 2, fullSystemPrompt: HARNESS }, ]) }) @@ -430,7 +556,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // wait until the stream is hanging, then cancel @@ -450,7 +576,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -475,16 +601,16 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('agent/step-end', () => void steps++) + ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) // Force exactly one continuation (step 1 → step 2), then defer to default // (step 2 is a plain stop with no tool calls → stops). ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => { - if (steps < 2) return true + if (steps < 2) return { action: 'continue' as const } return next() }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -506,7 +632,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -539,7 +665,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -581,7 +707,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -600,7 +726,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -640,7 +766,7 @@ describe('agent loop', () => { ]) }) - it('stops the turn when agent/step-end listener failure has recorded an error', async () => { + it('stops the turn when a step/end session-event listener failure has recorded an error', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'x' }), textResponse('should not run'), @@ -656,8 +782,11 @@ describe('agent loop', () => { })) 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') } + // A throwing step/end session-event listener is the surviving boundary-listener + // failure path (step boundaries have no agent/* mirror): closeStep contains it + // and surfaces it as a turn error rather than stranding the turn open. + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') } }) send(agent, 'go') @@ -674,13 +803,13 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) // queue two messages while idle — first starts turn 1 immediately; - // queue the second during turn 1 via a stream-chunk hook + // queue the second during turn 1 when the first assistant chunk streams let queued = false - ctx.on('agent/stream-chunk', () => { - if (!queued) { + ctx.on('session/event', (_s, event) => { + if (event.type === 'assistant/chunk' && !queued) { queued = true send(agent, 'second message') } @@ -721,7 +850,7 @@ describe('agent loop', () => { const errors: Error[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -766,7 +895,7 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }], + agents: [{ id: AgentId('config-agent'), model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 074e84d78a..805f61d392 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -94,6 +94,36 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) + it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => { + // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'. + const adapter1 = new MockAdapter([textResponse('a')]) + const { ctx: ctx1, root } = await persistentHarness(adapter1) + const sources1: string[] = [] + ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) + const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent + expect(sources1).toEqual(['startup']) + a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + await waitForIdle(ctx1, a1) + await ctx1.fiber.dispose() + + // Lifecycle 2: resuming the persisted session emits session-start 'resume'. + const adapter2 = new MockAdapter([textResponse('b')]) + const ctx2 = new Context() + await ctx2.plugin(LlmService) + await ctx2.plugin(SessionStore) + await ctx2.plugin(SystemPrompt) + await ctx2.plugin(ToolRegistry) + await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentLoop, { agents: [] }) + await ctx2.plugin(SessionPersistenceJsonl, { root }) + ctx2.llm.registerAdapter(['mock'], adapter2) + const sources2: string[] = [] + ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) + await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') }) + expect(sources2).toEqual(['resume']) + await ctx2.fiber.dispose() + }) + it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength // in its header) by creating it with a complete-turn seed — the write path diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 346d70e2f7..21be9e8267 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,10 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } 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' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -132,7 +132,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) describe('HIGH: steering from late extension points is never stranded', () => { - it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => { - const adapter = new MockAdapter([ - toolCallResponse('c1', 'echo', { text: 'x' }), - textResponse('after steering'), - ]) - const ctx = await harness(adapter) - ctx.tools.register(defineTool({ - name: 'echo', - description: '', - parameters: { text: { type: 'string' } }, - async execute(args) { - return [{ type: 'text', text: String(args.text) }] - }, - })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - - let steeredOnce = false - ctx.on('agent/step-end', () => { - if (steeredOnce) return - steeredOnce = true - agent.steer([{ type: 'text', text: 'goal reminder from step-end' }]) - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end') - }) - it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -199,20 +169,69 @@ describe('HIGH: steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) - 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')]) + it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => { + // The /goal pattern steers from a step boundary so the model addresses a + // standing goal before stopping. Step boundaries have no agent/* mirror, so + // the surviving hook point is the durable step/end session event. With a + // no-tools first step the default continuation is stop; the steering queued + // here must force the `!shouldContinue && hasSteering` override so the SAME + // turn runs another step. + // + // The override is what this test guards, so it asserts the same-turn shape — + // NOT merely that the content reaches requests[1]. Without the override the + // turn would stop, and leftover steering is re-enqueued as a next-turn queued + // message, which ALSO lands in requests[1] (just one turn later). So a + // content-only assertion passes with the override disabled and guards + // nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with + // TWO steps and the steering recorded as a `steering/message` BEFORE step 2; + // re-enqueue fallback ⇒ TWO turns. + const adapter = new MockAdapter([ + textResponse('no tools, would stop'), + textResponse('after goal reminder'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false - ctx.on('agent/turn-end', () => { - if (steeredOnce) return + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return steeredOnce = true - agent.steer([{ type: 'text', text: 'too late for this turn' }]) + agent.steer([{ type: 'text', text: 'goal reminder from step/end' }]) }) + send(agent, 'go') + await waitForIdle(ctx, agent) + + // Same-turn continuation: the steering forced step 2 within turn 1. + const events = [...agent.session.events] + expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(events.filter(e => e.type === 'step/start')).toHaveLength(2) + // The steered content is recorded as steering (same turn), BEFORE step 2 — + // not as a fresh turn's user/message. This is the mechanism the override uses. + const steeringIdx = events.findIndex(e => e.type === 'steering/message') + const step2Idx = events.map(e => e.type).lastIndexOf('step/start') + expect(steeringIdx).toBeGreaterThanOrEqual(0) + expect(steeringIdx).toBeLessThan(step2Idx) + // and it reached the next model request. + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') + }) + + it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { + const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + let steeredOnce = false + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && !steeredOnce) { + steeredOnce = true + agent.steer([{ type: 'text', text: 'too late for this turn' }]) + } + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -253,12 +272,12 @@ describe('HIGH: plugin exceptions are contained', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/turn-continuation', async (): Promise<boolean> => { + ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => { if (!threwOnce) { threwOnce = true throw new Error('broken continuation plugin') } - return false + return { action: 'stop' } }) const errors: Error[] = [] @@ -314,7 +333,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/status', (_agent, status) => void statuses.push(status)) - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -391,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/queued carries the resolved source; agent/steering carries its source', async () => { + it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -406,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => { })) const queuedSources: { source: MessageSource; steering: boolean }[] = [] - const steeringSources: MessageSource[] = [] ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) - ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source)) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false }) expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true }) + // The drain appends the durable steering/message with the caller's source + // intact — the log, not a transient emit, is where consumers read it. + const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) }) @@ -443,7 +463,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.effect(() => forked.start()) const turns: number[] = [] - ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.send([{ type: 'text', text: 'continue' }]) await new Promise<void>((resolve) => { ctx2.on('agent/status', (subject, status) => { @@ -487,7 +507,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete 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)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -512,7 +532,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete 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)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -530,7 +550,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete 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)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -539,24 +559,26 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete }) }) -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 () => { +describe('P1-6: a step/start session-event listener sees the event already in the log', () => { + it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) - // 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). + // Session.append pushes the event BEFORE notifying session/event listeners, + // so a step/start listener always finds the matching event already in the + // log. (Step boundaries have no agent/* mirror — the session log is the live + // feed.) const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] - ctx.on('agent/step-start', (subject, turn, step) => { - if (subject !== agent) return - const events = [...subject.session.events] + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session || event.type !== 'step/start') return + const events = [...subject.events] const last = events.at(-1) observed.push({ - turn, - step, + turn: event.data.turn, + step: event.data.step, lastEventType: last?.type, - sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step), + sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step), }) }) @@ -599,35 +621,23 @@ 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(AgentId('a-turnstart'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // 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', step: 0, message: 'boom turn-start' }) - // model was never called (we threw before the step's request). - expect(adapter.requests).toHaveLength(0) - }) - - it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => { + it('a throwing step/start session-event 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(AgentId('a-stepstart'), { model: 'mock' }) + // Step boundaries have no agent/* mirror; a throwing step/start session-event + // listener is the surviving step-boundary-listener failure. The loop marks + // the step open BEFORE appending step/start (Session.append pushes before + // notifying, so a post-push listener throw still leaves stepOpen=true), so + // the outer catch's closeStep() appends the balancing step/end — the turn + // stays enclosed. The invariants oracle (balancedHarness) rejects any + // imbalance, so a green run proves turn/start → step/start → step/end → + // turn/end nesting holds. let threw = false - ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } }) + ctx.on('session/event', (_s, event) => { + if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } + }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) @@ -638,7 +648,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) expect(errors.map(x => x.message)).toEqual(['boom step-start']) - // step/end must precede turn/end (the invariants oracle would reject + // step/end precedes turn/end (the invariants oracle would reject // turn/end-while-step-open, but assert the order explicitly too). const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') @@ -690,7 +700,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -707,46 +717,46 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar 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 () => { - // Dispose mid-step → the step-error branch sets reason=disposed (no error - // reported). closeTurn(true) then emits agent/turn-end, whose listener - // throws → control reaches the outer catch with isDisposed() && !errorReported, - // which must PRESERVE disposed rather than overwrite it with the listener's - // throw. This is the only path that exercises that catch sub-branch. - const adapter = new MockAdapter(['hang']) + it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { + // Reach the OUTER catch while disposed: an `agent/pre-step` listener requests + // disposal AND throws. The throw escapes the pre-step `await` (line ~419) to + // the loop's outer catch — BEFORE the post-pre-step disposal check at ~422 + // gets to run — so the catch sees `isDisposed() && !errorReported` and must + // PRESERVE reason=disposed rather than overwrite it with the listener's throw + // (disposal is not a failure). This is the surviving path to that sub-branch + // now that there is no turn-boundary emit to throw from. + const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - // The FIRST agent/turn-end emit throws (the disposal-driven turn end). let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end during disposal') } }) - // Collect agent/error emissions to prove none is surfaced through that - // channel either (the listener throw must be fully contained). + ctx.on('agent/pre-step', () => { + if (threw) return + threw = true + // Request disposal, then throw in the same synchronous tick: status flips + // to 'disposed' (the disposer aborts the step controller) and the throw + // drives control into the outer catch with isDisposed() already true. + void fiber.dispose() + throw new Error('boom pre-step during disposal') + }) const errorEmits: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() // dispose during the hanging step await agent.done - // The throwing turn-end listener actually fired — proving the outer-catch - // path was exercised, not skipped. - expect(threw).toBe(true) - const e = [...agent.session.events] - // Exactly one turn/start and one turn/end (balanced); the turn/end carries - // the disposed reason, NOT an error reason from the throwing listener. + // Balanced: one turn/start, one turn/end carrying disposed (NOT error). 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: 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) + // No step opened (the throw was before step/start) and disposal is not a + // failure, so no agent/error for the contained throw. + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -792,54 +802,20 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(adapter.requests).toHaveLength(1) }) - it('a throwing turn-end listener on a SUCCESSFUL turn leaves no event after turn/end (loadable log)', async () => { - // Regression: a normal turn completes, closeTurn(true) appends turn/end and - // emits agent/turn-end whose listener throws. The error must NOT be appended - // as a session event after turn/end — that would sit past the commit - // boundary and be dropped as a crash tail on resume (the turn-enclosure RFC). It is - // 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(AgentId('a-tend'), { model: 'mock' }) - - let threw = false - 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) - - const c = boundaryCounts(agent) - expect(c.turnEnd).toBe(1) - 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']) - - // loop survives. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(2) - }) - - it('a throwing agent/step-end listener during a successful step ends the turn as error, not completed', async () => { - // closeStep() must surface a throwing step-end listener via failTurn so the + it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => { + // closeStep() must surface a throwing step/end listener via failTurn so the // turn ends with reason error, not a silent "completed" with the throw // swallowed. Regression test for the closeStep() catch that previously - // swallowed the throw in the normal (no-tool, no-steering) path. + // swallowed the throw in the normal (no-tool, no-steering) path. (Step + // boundaries have no agent/* mirror; the session-event listener is the path.) const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) let threw = false - ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } }) + ctx.on('session/event', (_s, event) => { + if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } + }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) @@ -869,52 +845,19 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c2.stepStart).toBe(c2.stepEnd) }) - it('a step error followed by a throwing turn-end listener logs the error exactly once (no double-report)', async () => { - // The step fails (finish-error) → failTurn records ONE error and sets the - // error reason. closeTurn(true) then appends turn/end and emits - // agent/turn-end, whose listener throws → the outer catch calls failTurn - // again, but its errorReported guard makes it a no-op. Trap #1: exactly one - // error, the turn stays balanced. - 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(AgentId('a-double'), { model: 'mock' }) - - let threw = false - 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)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // 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', step: 1, message: 'provider down' }) - - // loop survives the compound failure. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(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 + // A finish-error stream opens a step then fails it, driving finalization + // through closeStep() with the step open. closeStep appends step/end; a // session/event listener throwing on THAT must not abort the catch before - // closeTurn(false) — step/end is already logged (balance holds) and the - // throw is contained + surfaced via failTurn, so turn/end is still appended. - const adapter = new MockAdapter([textResponse('never reached')]) + // closeTurn — step/end is already logged (balance holds) and the throw is + // contained + surfaced via failTurn, so turn/end is still appended. (The + // failed step itself also routes through failTurn; the step/end-listener + // throw is the second, contained, failure.) + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) - // Open a step, then make the agent/step-start emit throw (boundary throw → - // outer catch → closeStep during finalization). - ctx.on('agent/step-start', () => { throw new Error('boom step-start') }) let threw = false ctx.on('session/event', (_s, event) => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } @@ -941,11 +884,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => { // closeTurn appends turn/end; Session.append pushes it BEFORE notifying // session/event listeners, so a throwing listener leaves turn/end in the log - // (the turn is balanced) but must not escape — from the normal-path - // closeTurn(true) it would otherwise propagate; the append is contained so - // the turn/end emit + loop continue. (A throwing agent/turn-end LISTENER is - // a separate, already-tested path; here the session/event append notify is - // what throws.) + // (the turn is balanced) but must not escape — from the normal-path closeTurn + // it would otherwise propagate; the append is contained so the loop continues. + // Turn boundaries are durable session events only (no agent/* mirror), so this + // session/event append-notify throw is the sole turn-end-listener failure path. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) @@ -972,7 +914,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }) describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => { - it('a tools/execute listener returning a mismatched callId cannot orphan the call↔result pairing', async () => { + it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => { // Model emits a tool-call with id "c1", then a final text turn. const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { x: 1 }), @@ -986,12 +928,13 @@ describe('P1-7: tool/result is logged under the originating call.id, not result. async execute() { return [{ type: 'text', text: 'ok' }] }, })) - // A waterfall listener short-circuits with a result carrying the WRONG - // callId (a listener-internal/proxy id). The loop must still record the - // tool/result under the model's authoritative call.id. - ctx.on('tools/execute', (exec) => { + // A post-execute listener transforms the result (accept-with-replacement). + // The loop must still record the tool/result under the model's authoritative + // call.id (the loop ignores result.callId — which the registry always sets to + // exec.callId anyway — and uses call.id, the model-transcript id). + ctx.on('tools/post-execute', (exec, _result) => { expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in - return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false }) + return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' }) @@ -1073,7 +1016,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { ctx.llm.registerAdapter(['mock'], adapter) // Blocking listener on the parent context (survives fiber disposal). - const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocked return next() }) @@ -1084,7 +1027,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // Give the loop time to enter the step and reach assemble(). @@ -1110,10 +1053,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { // No step was opened, no LLM call was made. expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during assembly: the - // fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s - // emit, and the LIFO chain disposes effects in reverse registration order. - // The turn/end durable record is the one that matters. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror), so this asserts on the log. }) it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { @@ -1131,7 +1072,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(Invariants, { freeze: false }) ctx.llm.registerAdapter(['mock'], adapter) - const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) { + const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocker return next() }) @@ -1142,7 +1083,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1197,7 +1138,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1218,9 +1159,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during pre-step: the - // fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end - // is the authoritative record. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror). }) it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { @@ -1250,7 +1190,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -1289,7 +1229,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(Invariants, { freeze: false }) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('system-prompt/assemble', async function (_assembly, next) { + ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocker return next() }) @@ -1315,7 +1255,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - // The durable turn/end reason is the authoritative record; agent/turn-end - // may not fire when disposal interleaves with closeTurn(true)'s emit. + // The durable turn/end reason is the authoritative turn-boundary record + // (turn boundaries have no agent/* mirror). }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6a95d27572..a6446bf7d1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -31,25 +31,31 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/created`, `agent/disposed` — registration/deregistration - `agent/status` — idle / running / disposed transition - `agent/queued` — message entered inbox (source-resolved, steering flag) +- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees). -#### Turn/step boundaries (emit) +#### Boundaries are durable session events, not `agent/*` emits -- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) -- `agent/step-start`, `agent/step-end` +Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md). #### Interception seams +`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly): + +- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup). +- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`. - `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step. -- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering) -- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records) -- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard) +- `agent/request` — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering) +- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records) +- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard. -#### Streaming + tool (emit) +Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam. + +#### Error notifications (emit) -- `agent/stream-chunk` — raw chunk from the model (token-level UI/log feed) -- `agent/steering` — steering content injected mid-turn - `agent/error` — step/turn error +The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use). + ### Agent interface (`types.ts`) The handle every plugin programs against: diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index fa6946b8cf..e38a6c8d61 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -25,12 +25,14 @@ "@deepseek-ai/dsh-brand": "^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", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 940a5c9db1..096555f925 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -49,7 +49,7 @@ export interface CreateAgentOptions { * for a fresh (spawn) child. */ seed?: SessionEvent[] - /** Per-agent options (model, system prompt). */ + /** Per-agent options (model, …). */ agentOptions?: AgentOptions } @@ -62,7 +62,7 @@ export interface ResumeAgentOptions { agentId: AgentId /** The persisted session id to load and resume on. */ resumeSessionId: SessionId - /** Per-agent options (model, system prompt). */ + /** Per-agent options (model, …). */ agentOptions?: AgentOptions } @@ -126,6 +126,8 @@ export class AgentRegistry extends Service { * Register the agent-creation factory (the loop calls this on construction, * effect-scoped). Throws if a factory is already registered. Returns the * disposer; on dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @returns the disposer that clears the factory slot. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { @@ -142,6 +144,8 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Throws if no factory is * registered. Returns an {@link AgentHandle} — the owner disposes it to tear * down exactly this agent. + * @param options - agent id, session id/seed/metadata, and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ create(options: CreateAgentOptions): AgentHandle { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -152,6 +156,8 @@ export class AgentRegistry extends Service { * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured. Returns an {@link AgentHandle}. + * @param options - the persisted session id plus agent id and options. + * @returns the handle for the resumed agent. */ async resume(options: ResumeAgentOptions): Promise<AgentHandle> { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -162,6 +168,8 @@ export class AgentRegistry extends Service { * Register a live agent. Throws if an agent with the same id is already * registered. Emits `agent/created` on registration and `agent/disposed` * when the calling fiber is disposed. Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @returns the disposer that removes the agent and emits `agent/disposed`. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { @@ -200,10 +208,19 @@ export class AgentRegistry extends Service { return () => void dispose() } + /** + * Look up a live agent. + * @param id - the agent id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ get(id: AgentId): Agent | undefined { return this.store.get(id) } + /** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] { return [...this.store.values()] } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 407cce5250..1b4af61d9a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,11 +6,46 @@ * Merge-extensible: `AgentOptions` supports declaration merging for * plugin-specific creation options. * + * ## Event-domain semantics (the boundary rule) + * + * The harness has three event domains, each with one job: + * + * - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT + * log. Owns `SessionEventMap`; every entry is JSON-only (no live objects). + * One `session/event` emit per append, plus the `session/flush` parallel + * durability checkpoint. Answers "what happened, durably/replayably." A + * consumer that wants the live transcript subscribes here. + * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the + * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ + * `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and + * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits + * (`agent/status`, `agent/error`, `agent/created`/ + * `agent/disposed`, `agent/queued`, `agent/session-start`) + * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — + * they are durable `session/event` records. Answers "right now, with the agent + * object — intercept or observe." + * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. + * + * **The rule:** a durable, replayable fact is a SessionEvent; a live + * interception or a transient/live-object signal is an `agent`/`tools` Cordis + * event. A turn/step boundary is a durable fact: it lives in the session log + * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` + * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary + * keeps a session-id→agent map from `agent/created`/`agent/disposed`. + * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` + * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. + * + * The interception waterfalls here (`agent/prompt-submit`, `agent/request`, + * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision — + * the convention pinned by + * `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`. + * * @module @deepseek-ai/dsh-agent/types */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-system-prompt' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -19,17 +54,30 @@ export type AgentId = Branded<'AgentId'> export function AgentId(id: string): AgentId { return id as AgentId } -import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-system-prompt' { + interface AssembleContext { + /** + * The agent this assembly is for. The agent loop passes it on every + * per-step `assemble({ agent })`; variable providers project per-agent + * facts from it (`options.model` → `{{model}}`, `session.header.cwd` → + * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) + * has no agent — providers must tolerate its absence. + */ + agent?: Agent + } +} /** - * Options an agent is created with. + * Options an agent is created with. The persona is NOT here — it is the + * deployment's `persona` config on the dsh-system-prompt plugin, shared by + * every agent in the context. * Merge-extensible: plugins declare extra fields via declaration merging. */ export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string - /** Per-agent system prompt appended after the assembled sections. */ - systemPrompt?: string } export interface SendOptions { @@ -38,6 +86,68 @@ export interface SendOptions { export type AgentStatus = 'idle' | 'running' | 'disposed' +/** + * Model-facing context an interception listener wants the agent to SEE on the + * next request — the canonical shape behind every "inject extra context" + * decision ({@link PromptDecision}, {@link PostToolDecision}, + * {@link ContinuationDecision}). It is `agent.inject()`ed as a + * `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()` + * defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin + * context as a user prompt and corrupt derived history. A bridge sets + * `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not + * optional — the label is load-bearing, never defaulted here. + */ +export interface HookContext { + content: ContentBlock[] + source: MessageSource +} + +/** + * The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns + * for ONE drained queued message, before it becomes a `user/message`. Maps onto + * the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`. + * + * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt + * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a + * separate `context/message` the next request also sees. + * - `block` drops the prompt (it never becomes a `user/message`); `reason` is + * the durable record of why. The loop appends a `prompt/blocked` session event + * (carrying the original content, source, and `reason`) in place of the + * dropped `user/message`, so the veto survives replay even in a MIXED batch + * where a sibling prompt is allowed. A batch whose EVERY prompt is blocked + * additionally opens a zero-step turn that ends with {@link TurnEndReason} + * `rejected` (so the boundary stays balanced and a UI can render "blocked by + * hook"). + */ +export type PromptDecision = + | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; reason: string } + +/** + * The decision an {@link Agent} `agent/turn-continuation` waterfall listener + * returns. The loop computes the default (`continue` when the step had tool + * calls or steering was injected, else `stop`); listeners override it to + * force-continue (`/goal`, `/loop`) or force-stop (budget guards). + * + * A `continue` may carry a `reason`: model-facing context recorded as next-STEP + * steering within the SAME turn (the loop enqueues it through the steering + * channel, so the continued turn's next step sees it). This is the typed twin of + * the existing "steer from a step/end listener" `/goal` pattern. + */ +export type ContinuationDecision = + | { action: 'stop' } + | { action: 'continue'; reason?: HookContext } + +/** + * Why an agent's session lifecycle began, carried by `agent/session-start`. A + * bridge keys its SessionStart hook's matcher on this (Claude Code's + * `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create + * (including a seeded/forked create — a seed is NOT a resume); `resume` = a + * persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are + * driven by those subsystems (compact = `TODO(compaction)`). + */ +export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' + /** * The agent handle — the surface every plugin (UI, hooks, orchestrators) * programs against. The concrete implementation lives in @@ -132,12 +242,14 @@ declare module 'cordis' { /** * An agent was registered in the {@link AgentRegistry} and is ready to * receive messages. + * @param agent - the newly registered agent, already resolvable in the registry. * @mode emit */ 'agent/created'(agent: Agent): void /** * An agent was disposed and removed from the registry; its fiber and any * in-flight turn have been torn down. + * @param agent - the agent that was torn down; its handle is now inert. * @mode emit */ 'agent/disposed'(agent: Agent): void @@ -145,39 +257,41 @@ declare module 'cordis' { * Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive * lifecycle off this transition, never off a status you just requested — * `send()` does not flip status to `running` before it returns. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). * @mode emit */ 'agent/status'(agent: Agent, status: AgentStatus): void /** * A message entered the agent's inbox (queued or steering). `source` is * the resolved source (defaults applied), not the caller's raw options. + * @param agent - the agent whose inbox received the message. + * @param content - the enqueued content blocks, verbatim. + * @param info - the resolved source plus whether it entered as steering. * @mode emit */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void - // ---- turn/step boundaries (emit) ---- + // ---- session lifecycle (emit) ---- /** - * A turn began. `turn` is the 1-based turn number within the session. + * The agent's session lifecycle began, fired once before its first turn. + * `source` says why ({@link SessionStartSource}: fresh startup, a resumed + * persisted session, …). A pure NOTIFICATION (emit, not waterfall): it + * carries no veto — a session-start listener that wants to seed context does + * so via `agent.inject()` (a `context/message` the first request sees), not + * by returning a decision. Cannot block the session from starting; that gap + * is deliberate (a bridge logs/injects, it does not gate startup). + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). * @mode emit */ - 'agent/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 + 'agent/session-start'(agent: Agent, source: SessionStartSource): void + + // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer + // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ + // `step/end` session events off the `session/event` feed (the session log is + // the live transcript feed). See the module doc's three-domain rule and the + // "remove agent boundary mirror events" RFC. // ---- step/request extension seams (serial + waterfall) ---- /** @@ -204,6 +318,11 @@ declare module 'cordis' { * listener needs to measure pressure (the system prompt counts toward the * budget). `signal` cancels any in-flight work a listener starts (e.g. a * summarization model call). + * @param agent - the agent about to open the step. + * @param turn - the already-open turn this step belongs to. + * @param step - the number of the step about to start. + * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. + * @param signal - aborts in-flight listener work when the turn is torn down. * @mode serial */ // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction @@ -212,43 +331,64 @@ declare module 'cordis' { // prompt provider, or move token-pressure measurement behind a // compaction-specific seam instead of the shared pre-step checkpoint. 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void + /** + * Waterfall: decide what happens to ONE drained queued message before it + * becomes a `user/message` — allow (optionally rewriting the prompt bytes or + * attaching `additionalContext`) or block it. Fires inside the already-open + * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. + * Call `next()` to delegate to the default (allow unchanged), or return a + * {@link PromptDecision} without calling `next()` to short-circuit. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. + * @mode waterfall + */ + 'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision> /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the * model call (hooks, model switching, tool filtering, …). Call `next()` to * delegate, or return without it to short-circuit. For surface mutation that * must precede history derivation (compaction), use {@link agent/pre-step} * instead — by the time this fires, `options.messages` is already derived. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param options - the assembled request; listeners return a transformed copy. * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions> /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. * @mode waterfall */ 'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message> /** - * 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). + * Waterfall: override the turn-continuation decision via a typed + * {@link ContinuationDecision}. The loop's `defaultDecision` is `continue` + * when the step had tool calls or steering was injected, else `stop`. + * Listeners force-continue (`/goal`, `/loop` — optionally attaching a + * `reason` recorded as next-step steering) or force-stop (budget guards). + * Call `next()` to delegate to the default, or return a decision to override. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. * @mode waterfall */ - 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean> + 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision> - // ---- streaming + tool notifications (emit) ---- - /** - * 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. - * @mode emit - */ - 'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void + // ---- error notifications (emit) ---- /** * A step or turn errored. The loop reports a failure here (plus the logger) * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. * @mode emit */ 'agent/error'(agent: Agent, turn: number, step: number, error: Error): void diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index ee2ce47699..9eac8587a4 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -4,17 +4,20 @@ * The generated catalog is frozen by a regenerate-and-diff freshness gate, so * the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI. * What a freshness diff CANNOT prove is that the generator REJECTS malformed - * source the way it promises to — a missing `@mode` tag, or a tag that - * contradicts the signature shape. These tests drive `collectEvents()` against - * synthetic fixture packages to prove each guard fires (and that a well-formed - * event passes), mirroring the drift-guard negative tests for verify-type-equiv. + * source the way it promises to — a missing `@mode` tag, a tag that + * contradicts the signature shape, or a JSDoc-completeness violation (missing + * prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an + * unannotated return type). These tests drive `collectEvents()` / + * `collectServices()` against synthetic fixture packages to prove each guard + * fires (and that well-formed declarations pass), mirroring the drift-guard + * negative tests for verify-type-equiv. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ @@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string { return root } +/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` → + * `FixService`) plus the class source, and return the scan root to hand + * `collectServices`. */ +function serviceFixtureRoot(classSource: string): string { + const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) + const dir = join(root, 'packages', 'group', 'fix', 'src') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'index.ts'), + `declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, + ) + return root +} + const roots: string[] = [] const make = (block: string): string => { const r = fixtureRoot(block) roots.push(r) return r } +const makeService = (classSource: string): string => { + const r = serviceFixtureRoot(classSource) + roots.push(r) + return r +} afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) @@ -43,7 +65,7 @@ afterEach(() => { describe('gen-cordis-catalog collectEvents', () => { it('extracts a well-formed event with its @mode and JSDoc', () => { const events = collectEvents(make( - ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', )) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) @@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => { it('classifies a trailing-next signature as a waterfall', () => { const events = collectEvents(make( - ' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>', + ' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>', )) expect(events[0]?.mode).toBe('waterfall') }) @@ -65,19 +87,154 @@ describe('gen-cordis-catalog collectEvents', () => { it('hard-errors when an event is missing its @mode tag', () => { expect(() => collectEvents(make( - ' /** No mode here. */\n \'fix/untagged\'(id: string): void', + ' /** No mode here. */\n \'fix/untagged\'(): void', ))).toThrow(/missing an @mode tag/) }) it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => { expect(() => collectEvents(make( - ' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>', + ' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>', ))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/) }) it('hard-errors when @mode waterfall has no trailing next to delegate to', () => { expect(() => collectEvents(make( - ' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', + ' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', ))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/) }) + + it('hard-errors on an undocumented payload parameter', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/is missing @param id/) + }) + + it('hard-errors on a stale @param naming no real parameter', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/@param ghost does not match any parameter/) + }) + + it('hard-errors on an @param with an empty description', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/@param id has an empty description/) + }) + + it('hard-errors on an event whose JSDoc has no description prose', () => { + expect(() => collectEvents(make( + ' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/no description prose/) + }) + + it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => { + const events = collectEvents(make( + ' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>', + )) + expect(events).toHaveLength(1) + }) + + it('hard-errors on a binding-pattern parameter @param cannot name', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/destructured\'({ id }: { id: string }): void', + ))).toThrow(/is a binding pattern/) + }) + + it('aggregates every violation into one error instead of failing fast', () => { + expect(() => collectEvents(make( + ' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void', + ))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/) + }) +}) + +describe('gen-cordis-catalog collectServices', () => { + const WELL_FORMED = `/** Fixture service. */ +export class FixService { + /** + * Do the thing. + * @param id - which thing to do. + * @returns the outcome of doing it. + */ + run(id: string): string { return id } + + /** Fire and forget (void needs no @returns). */ + poke(): void {} + + /** Flush (Promise<void> needs no @returns either). */ + flush(): Promise<void> { return Promise.resolve() } +}` + + it('extracts a well-formed service with its methods and class JSDoc', () => { + const services = collectServices(makeService(WELL_FORMED)) + expect(services).toHaveLength(1) + expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' }) + expect(services[0]?.methods).toHaveLength(3) + }) + + it('hard-errors on a public method with no JSDoc at all', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}', + ))).toThrow(/ctx\.fix\.run .* has no JSDoc/) + }) + + it('hard-errors on an undocumented method parameter', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/ctx\.fix\.run .* is missing @param id/) + }) + + it('hard-errors on a missing @returns for a non-void return type', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/is missing @returns \(return type: string\)/) + }) + + it('hard-errors on an unannotated (inferred) return type', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}', + ))).toThrow(/no return type annotation/) + }) + + it('hard-errors on a service class with no JSDoc', () => { + expect(() => collectServices(makeService( + 'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}', + ))).toThrow(/class FixService has no JSDoc/) + }) + + it('hard-errors on a stale method @param', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}', + ))).toThrow(/@param ghost does not match any parameter/) + }) + + it('hard-errors on a method whose JSDoc is tags with no description prose', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * @param id - which thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/no description prose above its block tags/) + }) + + it('hard-errors on a method @param with an empty description', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param id\n */\n poke(id: string): void {}\n}', + ))).toThrow(/@param id has an empty description/) + }) + + it('hard-errors on an @returns with an empty description', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n * @returns\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/@returns has an empty description/) + }) + + it('hard-errors on a binding-pattern method parameter @param cannot name', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n */\n run({ id }: { id: string }): void {}\n}', + ))).toThrow(/is a binding pattern/) + }) + + it('ignores private/protected/static members (not the ctx.<key> surface)', () => { + const services = collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}', + )) + expect(services[0]?.methods).toHaveLength(0) + }) }) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 4d23ac46d3..7f4f457598 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" } ] } diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 45512c9d9e..72ca52bfef 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,9 +49,9 @@ 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`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. -Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`. +Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog. Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cef5e93e91..fa9bbb5ba4 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,6 +16,7 @@ import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' export * from './types.ts' export { isJsonValue } from './json.ts' +export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' @@ -29,12 +30,15 @@ declare module 'cordis' { interface Events { /** * A session was created in the store. + * @param session - the session just entered and announced. * @mode emit */ 'session/created'(session: Session): void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. * @mode emit */ 'session/event'(session: Session, event: SessionEvent): void @@ -44,6 +48,7 @@ declare module 'cordis' { * plugins (JSONL, SQLite) drain their write-behind buffers here and on * fiber dispose. Awaited (parallel), not a waterfall: every listener runs * and the loop waits for all of them, but none can veto. + * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ 'session/flush'(session: Session): Promise<void> | void @@ -341,6 +346,9 @@ export class SessionStore extends Service { * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s * `startOwned`). * + * @param id - the session id; omitted, the store mints `session-<n>`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ @@ -366,6 +374,9 @@ export class SessionStore extends Service { * chain rather than as racing sibling effects — which would detach `onAppend` * before the loop's closing `session/flush`, dropping the closing events. * + * @param id - the session id; omitted, the store mints `session-<n>`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path. */ @@ -403,6 +414,8 @@ export class SessionStore extends Service { * the two back-to-back so they never trip this, but the public seam cannot * assume that. * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (`onAppend = undefined` + store removal). * @throws if a session with this id is already in the store. */ enter(session: Session): () => void { @@ -417,15 +430,25 @@ export class SessionStore extends Service { /** Emit `session/created` for an {@link enter}ed session. Separate from * {@link enter} so the caller can yield the detach disposer first (rollback - * safety — see {@link enter}). */ + * safety — see {@link enter}). + * @param session - the entered session to announce to listeners. */ announce(session: Session): void { this.ctx.emit('session/created', session) } + /** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined { return this.store.get(id) } + /** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] { return [...this.store.values()] } diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 6b830afdff..47197b7b90 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -13,6 +13,16 @@ * @module @deepseek-ai/dsh-session/json */ +/** + * A value that round-trips losslessly through JSON: `null`, a boolean, a finite + * number, a string, an array of such values, or a plain object whose values are + * such values. The static type companion to {@link isJsonValue} (which validates + * the same shape at runtime). Use it to type a payload that must survive + * session-log persistence and replay byte-identically — e.g. a tool's private + * presentation `meta`. + */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + /** * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, * booleans, strings, plain arrays, and plain objects of such values. Rejects diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6ed8c4391e..82e080aa1a 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -91,7 +91,6 @@ export interface CreateSessionOptions { */ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn @@ -134,6 +133,16 @@ export interface TurnEndReasonMap { error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } + /** + * The turn's entire prompt batch was BLOCKED before any step ran — every + * drained queued message was vetoed by an `agent/prompt-submit` listener (a + * hook). The turn still opened (so the boundary stays balanced and the block + * is a durable in-turn fact), but ran zero steps. `reason` carries the block + * message from the vetoing decision. Distinct from `aborted` (a user-driven + * cancel) and `error` (a failure): the prompt was rejected by policy, not + * interrupted or broken. A UI renders it as "prompt blocked by hook". + */ + rejected: { kind: 'rejected'; reason: string } /** * The turn never ended on its own: the process crashed mid-turn and a * persistence backend later closed the orphaned (open) turn on reload so the @@ -188,12 +197,36 @@ export interface TodoItem { * the invariants plugin checks, is a breaking change to the on-disk format. */ export interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — a drained message + * batch or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * fires the awaited `session/flush` checkpoint at every turn end, so the turn + * boundary is also the durable-commit boundary. + */ 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * A queued prompt an `agent/prompt-submit` listener VETOED — the durable + * record of a blocked prompt and why. Appended in place of the `user/message` + * the prompt would have become, so the block survives replay even in a MIXED + * batch where another queued prompt is allowed (there the turn does not end + * `rejected`, so the boundary reason alone would not preserve it). `content` + * is the original prompt the listener rejected; `reason` is the veto text + * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a + * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history @@ -209,8 +242,22 @@ export interface SessionEventMap { * usage record). `usage` is absent when the adapter reported none. */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } + /** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts new file mode 100644 index 0000000000..bc4ab4fbb1 --- /dev/null +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -0,0 +1,225 @@ +/** + * Negative-path tests for the persistence log catalog generator + * (`scripts/gen-persistence-catalog.ts`). + * + * The generated catalog is frozen by a regenerate-and-diff freshness gate, so + * the freshness half is exercised by `pnpm run verify-persistence-catalog` in + * CI. What a freshness diff CANNOT prove is that the generator REJECTS + * malformed source the way it promises to — a member without description + * prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event + * declaration, a missing or ambiguous `SurfaceEventType` union, a stale union + * member. These tests drive the exported collectors against synthetic fixture + * packages to prove each guard fires (and that well-formed declarations pass), + * mirroring the gen-cordis-catalog negative tests. + */ + +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 { + annotateSurface, + collectLogEvents, + collectSurfaceEventTypes, + render, +} from '../../../../scripts/gen-persistence-catalog.ts' + +/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */ +function fixtureRoot(files: Record<string, string>): string { + const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-')) + for (const [rel, source] of Object.entries(files)) { + const abs = join(root, rel) + mkdirSync(join(abs, '..'), { recursive: true }) + writeFileSync(abs, source) + } + return root +} + +const roots: string[] = [] +const make = (files: Record<string, string>): string => { + const r = fixtureRoot(files) + roots.push(r) + return r +} + +/** A merge-form declaration file wrapping `members` in the session module. */ +const merge = (members: string): string => + `declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n` + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) +}) + +/** The manifest that marks a fixture package as the owning session package. */ +const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n' + +describe('gen-persistence-catalog collectLogEvents', () => { + it('extracts a documented member of the owning top-level interface', () => { + const events = collectLogEvents(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': + 'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n', + })) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + name: 'fix/happened', + scope: 'fix', + doc: 'A thing was recorded.', + payload: '{ turn: number }', + source: 'packages/core/fix/src/types.ts:3', + }) + }) + + it('hard-errors on a top-level interface outside the owning package', () => { + expect(() => collectLogEvents(make({ + 'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n', + 'packages/group/alien/src/types.ts': + 'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n', + }))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/) + }) + + it('hard-errors on a non-exported top-level interface even in the owning package', () => { + expect(() => collectLogEvents(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/helper.ts': + 'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n', + }))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/) + }) + + it('hard-errors when the owning interface is exported from two files', () => { + expect(() => collectLogEvents(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n', + 'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n', + }))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/) + }) + + it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': + 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n', + }))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/) + }) + + it('extracts a member declaration-merged via the session module', () => { + const events = collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'), + })) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' }) + }) + + it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => { + const events = collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge( + ' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }', + ), + })) + expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }') + }) + + it('hard-errors on a member with no description prose', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'), + }))).toThrow(/no description prose/) + }) + + it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'), + }))).toThrow(/carries an @mode tag/) + }) + + it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'), + }))).toThrow(/carries an @mode tag/) + }) + + it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'), + }))).toThrow(/not a property signature with an explicit payload type/) + }) + + it('hard-errors on a property member with no payload type annotation', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''), + }))).toThrow(/not a property signature with an explicit payload type/) + }) + + it('hard-errors on a non-literal member name', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'), + }))).toThrow(/non-literal name/) + }) + + it('hard-errors when the same event is declared twice', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'), + 'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'), + }))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/) + }) + + it('aggregates every violation into one error instead of failing fast', () => { + expect(() => collectLogEvents(make({ + 'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'), + }))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/) + }) +}) + +describe('gen-persistence-catalog collectSurfaceEventTypes', () => { + it('parses the literal union', () => { + const types = collectSurfaceEventTypes(make({ + 'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n', + })) + expect(types).toEqual(['fix/a', 'fix/b']) + }) + + it('hard-errors when no union is declared', () => { + expect(() => collectSurfaceEventTypes(make({ + 'packages/core/fix/src/types.ts': 'export const unrelated = 1\n', + }))).toThrow(/no SurfaceEventType union found/) + }) + + it('hard-errors when the union is declared more than once', () => { + expect(() => collectSurfaceEventTypes(make({ + 'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n', + 'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n', + }))).toThrow(/declared more than once/) + }) + + it('hard-errors on a non-string-literal union member', () => { + expect(() => collectSurfaceEventTypes(make({ + 'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n', + }))).toThrow(/non-string-literal member/) + }) +}) + +describe('gen-persistence-catalog annotateSurface + render', () => { + const entry = (name: string) => ({ + name, + scope: name.split('/')[0] ?? name, + payload: '{ turn: number }', + doc: `Records ${name}.`, + source: 'packages/core/fix/src/types.ts:3', + }) + + it('badges union members surface and everything else log-only', () => { + const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']) + expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]]) + }) + + it('hard-errors on a union member naming no declared event', () => { + expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost'])) + .toThrow(/'fix\/ghost' name no declared log event/) + }) + + it('renders badges, payload fences, and the generated-file header', () => { + const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])) + expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts') + expect(out).toContain('#### `fix/message` — surface') + expect(out).toContain('#### `fix/marker` — log-only') + expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```') + }) +}) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 1c18bdf1d6..be705de03e 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,37 +1,48 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(): Promise<PromptAssembly>` Assemble the current prompt. Runs through the `system-prompt/assemble` waterfall. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. ### Events | Event | Mode | Purpose | |---|---|---| -| `system-prompt/assemble` | waterfall | Mutate/extend the assembly before it reaches the model | -| `system-prompt/change` | emit | A section or tool provider was registered or unregistered | +| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model | +| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered | ### Key types -- `PromptSection` — `{ name, order, text: string | (() => string) }`. Sections are concatenated in ascending `order`. -- `PromptAssembly` — `{ sections: PromptSection[], tools: ToolSchema[] }`. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. -- `renderPrompt(assembly)` — joins section texts with blank lines. +- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context). +- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona. +- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. +- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. -Merge-extensible: plugins can declare extra fields on `PromptAssembly` via declaration merging. +Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging. ### Extension points -- Section providers: AGENTS.md reader, cwd notifier, persona config, etc. +- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. +- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The `system-prompt/assemble` waterfall: mutate or replace the assembly (system-prompt configurability, dynamic tool filtering). +- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables). ### What is NOT here -- Any hardcoded prompt text — every section comes from plugins. +- Any deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.) - Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). + +Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 672f7a03ef..d97a7b8538 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -25,6 +25,9 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.6" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index a5e6e4ed78..c970c66b30 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,12 +1,19 @@ /** - * System prompt assembly registry. Plugins contribute ordered text sections and - * tool schema providers; `assemble()` collates them through a waterfall that - * runs once per step. + * System prompt assembly registry. Plugins contribute ordered text sections, + * tool schema providers, and named prompt variables; `assemble(context)` + * collates them through a waterfall that runs once per step, and + * `renderPrompt` interpolates `{{variable}}` references into the final text. + * + * The harness-owned prompt openers live here too: this plugin registers the + * static `harness:identity` section (order −100) and the deployment's + * `deployment:persona` section (order 0, from its `persona` config), so they + * exist for every agent regardless of which loop plugin drives it. * * @module @deepseek-ai/dsh-system-prompt */ import { Context, Service } from 'cordis' +import z from 'schemastery' import type { ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { @@ -17,28 +24,63 @@ declare module 'cordis' { interface Events { /** * 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. + * {@link PromptAssembly} (sections + tools + variables) before it is + * rendered. Bound to the {@link SystemPrompt} service; call `next()` to + * delegate. + * @param assembly - the assembly built from the registered sections, tool + * providers, and variable providers; listeners may mutate it or return a + * replacement. + * @param context - the per-assembly {@link AssembleContext} the caller + * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt + * is for), so a listener can filter or extend per agent. * @mode waterfall */ - 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly> + 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly> /** - * A section or tool provider was registered or unregistered (the assembly - * inputs changed). + * A section, tool provider, or variable provider was registered or + * unregistered (the assembly inputs changed). * @mode emit */ 'system-prompt/change'(): void } } -/** One contributed section of the system prompt. */ +/** + * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR. + * Declared empty here so this package stays agnostic of who assembles; + * merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so + * section text and variable providers can be functions of the calling agent. + * Every field is optional by nature: a bare `assemble()` (tests, diagnostics) + * carries an empty context, and providers must tolerate absent fields. + */ +export interface AssembleContext {} + +/** One contributed section of the system prompt (registry input). */ export interface PromptSection { - /** Unique name (diagnostics / dedup). */ + /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ name: string - /** Sections are concatenated in ascending order. */ + /** + * Sections are concatenated in ascending order. Convention: `-100` is the + * harness identity, `0` the deployment persona, tool guidance uses 100–199; + * other negative orders also render before the persona. + */ order: number - /** Static text or a provider evaluated at each assembly. */ - text: string | (() => string) + /** + * Static text or a provider evaluated at each assembly with that assembly's + * {@link AssembleContext}. The text may reference `{{variable}}`s — they are + * interpolated later, by {@link renderPrompt}. + */ + text: string | ((context: AssembleContext) => string) +} + +/** One section of an assembly: {@link PromptSection} with its text resolved. */ +export interface AssembledSection { + /** The contributing section's unique name. */ + name: string + /** The contributing section's order (sections arrive sorted ascending). */ + order: number + /** The resolved (but not yet interpolated) section text. */ + text: string } /** @@ -48,41 +90,156 @@ export interface PromptSection { * can do" is one coherent thing managed here, even though adapters transmit * `tools` as a separate wire field rather than prompt text. * + * `variables` carries every registered prompt variable resolved against this + * assembly's context — key present means registered, `undefined` value means + * "no value for this assembly" (referencing it renders an error). Section + * texts are resolved but NOT yet interpolated; {@link renderPrompt} applies + * the variables, so waterfall listeners can still add sections or variables. + * * Merge-extensible: plugins can declare extra fields on this interface. */ export interface PromptAssembly { - sections: PromptSection[] + sections: AssembledSection[] tools: ToolSchema[] + variables: Record<string, string | undefined> } -/** Renders the text part of an assembly (sections joined by blank lines). */ +/** Valid variable names: how they are written between the braces. */ +const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ + +/** A complete `{{...}}` reference group at the scan position (validated after). */ +const GROUP_AT = /^\{\{([^{}]*)\}\}/ + +export interface Config { + /** + * The deployment's persona — the ONE deployment-authored fragment of the + * system prompt, rendered as the order-0 `deployment:persona` section + * (after the harness identity, before all tool guidance). Every agent in + * the context shares it, subagents included. Template, not free-form text: + * every complete `{{…}}` group is interpreted strictly against the + * registered prompt variables (the shipped agent loop registers `{{model}}` + * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose + * yet (a deliberate deferral; see the prompt-variables RFC). Defaults to + * `''` — the empty section is dropped at render, so a persona-less + * deployment opens with the harness identity alone. + */ + persona?: string +} + +/** + * Renders the text part of an assembly: interpolates `{{variable}}` + * references in each section from `assembly.variables`, drops empty sections, + * and joins the rest with blank lines. + * + * Strict by design (fail loud beats shipping a malformed prompt): a reference + * to an unregistered variable, to a registered variable with no value for + * this assembly, a complete `{{…}}` group that is not a well-formed variable + * name (e.g. `{{ model }}`), or a `{{` that does not open a complete group + * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A + * lone `{{` with no `}}` anywhere after it is ordinary prose and passes + * through verbatim. Substituted values are never re-scanned. + */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections - .map(section => typeof section.text === 'function' ? section.text() : section.text) + .map(section => interpolate(section, assembly.variables)) .filter(text => text.length > 0) .join('\n\n') } +/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */ +function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string { + const text = section.text + let result = '' + let last = 0 + for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) { + const group = GROUP_AT.exec(text.slice(open)) + if (group === null) { + // No complete simple group starts at this `{{`. A `}}` further on means + // a mangled reference (extra or nested braces) — fail loud. With no + // closing `}}` anywhere after, it is ordinary prose (shell, JSON) and + // passes through verbatim. + if (text.indexOf('}}', open + 2) >= 0) { + throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`) + } + result += text.slice(last, open + 2) + last = open + 2 + continue + } + // group[0] is the whole `{{...}}` match (a plain string, no optional + // index): the name is its interior. `{{}}` yields '' → the malformed path. + const name = group[0].slice(2, -2) + if (!VARIABLE_NAME.test(name)) { + throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`) + } + // Object.hasOwn, NOT `in`: `in` walks the prototype chain, so an + // unregistered `{{constructor}}` would resolve to Object.prototype's and + // splice a function's source text into the prompt instead of throwing. + if (!Object.hasOwn(variables, name)) { + const known = Object.keys(variables) + throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`) + } + const value = variables[name] + if (value === undefined) { + throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`) + } + result += text.slice(last, open) + value + last = open + group[0].length + } + return result + text.slice(last) +} + /** * Registry service (`ctx.systemPrompt`): plugins contribute ordered text - * sections and tool-schema providers; the agent loop calls `assemble()` once - * per step. + * sections, tool-schema providers, and named prompt variables; the agent loop + * calls `assemble(context)` once per step. Registers the harness-owned + * `harness:identity` and `deployment:persona` sections itself (see + * {@link Config.persona}). */ export class SystemPrompt extends Service { + static Config: z<Config> = z.object({ + persona: z.string().default(''), + }) + private sections: PromptSection[] = [] private toolProviders: (() => ToolSchema[])[] = [] + private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>() - constructor(ctx: Context) { + constructor(ctx: Context, public config: Config) { super(ctx, 'systemPrompt') + // The harness-owned openers. They live HERE (not on the loop plugin) so a + // deployment that swaps in a different loop keeps them: the identity is a + // harness fact stated ahead of everything, and the persona is the + // deployment's config, one section of the full prompt, never the whole. + // An empty persona still RESERVES the section name (one owner — a plugin + // re-registering it throws); renderPrompt drops the empty text. + this.section({ + name: 'harness:identity', + order: -100, + text: 'You are an AI agent powered by the DeepSeek Harness SDK.', + }) + this.section({ + name: 'deployment:persona', + order: 0, + // The schema already defaulted an omitted persona to ''; the ?? only + // narrows the optional-input TYPE, it never supplies a different value. + text: config.persona ?? '', + }) } /** * Contribute a text section to the system prompt. Order is determined by - * `section.order` (ascending). The section is removed when the calling + * `section.order` (ascending). Throws if a section with the same name is + * already registered (a duplicate would silently double prompt text — e.g. + * a double-loaded tool plugin). The section is removed when the calling * fiber is disposed. Emits `system-prompt/change` on register/unregister. + * @param section - the section to contribute (name, order, text or provider). + * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { + if (this.sections.some(existing => existing.name === section.name)) { + throw new Error(`prompt section "${section.name}" is already registered`) + } this.sections.push(section) // Yield the rollback BEFORE emitting `system-prompt/change`: a generator // effect collects each yielded disposer before the next step runs, so a @@ -105,6 +262,8 @@ export class SystemPrompt extends Service { * Contribute a tool-schema provider that is evaluated at each assembly * call (so it can reflect the live registry state). The provider is * removed when the calling fiber is disposed. Emits `system-prompt/change`. + * @param provider - evaluated at every {@link assemble} for fresh schemas. + * @returns the disposer that removes the provider. */ tools(provider: () => ToolSchema[]): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -124,24 +283,71 @@ export class SystemPrompt extends Service { } /** - * Assemble the current prompt (sections sorted by order, tools collected - * from all providers). Section records are top-level clones (the `text` - * provider may be a function and is intentionally shared); tool schemas are - * deep-cloned because adapters and request waterfalls may mutate schema - * objects. Runs through the `system-prompt/assemble` waterfall, giving - * listeners the opportunity to mutate or replace the assembly before it - * reaches the model. Await the result before reading the assembly values — - * waterfall listeners may be async. + * Contribute a named prompt variable, referenced from section text as + * `{{name}}`. The provider is evaluated at each assembly with that + * assembly's {@link AssembleContext}; returning `undefined` means "no value + * for this assembly" (a section referencing it then fails to render — a + * deployment must not claim facts it does not have). Throws on a name that + * does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is + * already registered. Removed when the calling fiber is disposed; emits + * `system-prompt/change` on register/unregister. + * @param name - the reference name (matches `[a-z][a-z0-9_]*`). + * @param provider - evaluated at every {@link assemble} for the value. + * @returns the disposer that removes the variable. */ - assemble(): Promise<PromptAssembly> { + variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { + const dispose = this.ctx.effect(function* (this: SystemPrompt) { + if (!VARIABLE_NAME.test(name)) { + throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) + } + if (this.variableProviders.has(name)) { + throw new Error(`prompt variable "${name}" is already registered`) + } + this.variableProviders.set(name, provider) + // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + yield () => { + this.variableProviders.delete(name) + this.ctx.emit('system-prompt/change') + } + this.ctx.emit('system-prompt/change') + }.bind(this), 'systemPrompt.variable()') + // ctx.effect's disposer returns Promise<void>; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** + * Assemble the current prompt for one caller: section texts are resolved + * against `context` and sorted by order, tools collected from all + * providers, and every registered variable resolved against `context` into + * `assembly.variables`. Tool schemas are deep-cloned because adapters and + * request waterfalls may mutate schema objects. Runs through the + * `system-prompt/assemble` waterfall, giving listeners the opportunity to + * mutate or replace the assembly before it reaches the model. Await the + * result before reading the assembly values — waterfall listeners may be + * async. Interpolation happens later, in {@link renderPrompt}. + * @param context - what this assembly is for (defaults to an empty context; + * see {@link AssembleContext}). + * @returns the assembly after the waterfall has run. + */ + assemble(context: AssembleContext = {}): Promise<PromptAssembly> { + const variables: Record<string, string | undefined> = {} + for (const [name, provider] of this.variableProviders) { + variables[name] = provider(context) + } const assembly: PromptAssembly = { sections: this.sections - .map(section => ({ ...section })) + .map(section => ({ + name: section.name, + order: section.order, + text: typeof section.text === 'function' ? section.text(context) : section.text, + })) .sort((a, b) => a.order - b.order), tools: this.toolProviders.flatMap(provider => provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))), + variables, } - return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly)) + return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly)) } } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index cfbf36cbec..c0bd8be6d2 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -1,21 +1,81 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SystemPrompt, { PromptAssembly, PromptSection, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt' + +/** + * Every assembly carries the plugin's own built-ins — `harness:identity` + * (order −100) and `deployment:persona` (order 0, from config). Tests about + * registry MECHANICS strip them with {@link contributed} to stay focused on + * their own sections; the built-ins' behavior is pinned by its own describe. + */ +const BUILT_IN = ['harness:identity', 'deployment:persona'] +const IDENTITY = 'You are an AI agent powered by the DeepSeek Harness SDK.' +function contributed(assembly: PromptAssembly): PromptAssembly['sections'] { + return assembly.sections.filter(section => !BUILT_IN.includes(section.name)) +} describe('SystemPrompt', () => { - it('assembles sections in order with dynamic text and collected tools', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) + describe('built-in sections', () => { + it('registers the harness identity and the configured deployment persona', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(s => [s.name, s.order])).toEqual([ + ['harness:identity', -100], + ['deployment:persona', 0], + ]) + expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`) + // The names are reserved by the plugin — one owner per section. + expect(() => ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'imposter' })) + .toThrow('prompt section "deployment:persona" is already registered') + }) + + it('renders no persona section for a persona-less deployment (empty default)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY) + }) + + it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { + // ctx.plugin validates + defaults the config first; a direct construction + // skips the schema, so the ctor's `?? ''` narrowing is what fires. + const ctx = new Context() + const service = new SystemPrompt(ctx, {}) + expect(renderPrompt(await service.assemble())).toBe(IDENTITY) + }) + }) + + it('assembles sections in order with context-resolved text and collected tools', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' }) - ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' }) ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' }) ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' }) ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }]) const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.map(s => s.name)).toEqual(['persona', 'rules', 'cwd']) + expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd']) + expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp']) expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }]) - expect(renderPrompt(assembly)).toBe('You are DeepSeek Code.\n\nBe precise.\n\ncwd: /tmp') + expect(assembly.variables).toEqual({}) + expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`) + }) + + it('resolves section text providers against the assemble context, at each assemble call', async () => { + // The context is HOW per-agent sections work (the loop passes { agent }); + // this spec stays agent-agnostic and smuggles a marker through a plain field. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let calls = 0 + ctx.systemPrompt.section({ + name: 'dynamic', + order: 0, + text: (context: AssembleContext) => `call ${++calls} for ${(context as { who?: string }).who ?? 'nobody'}`, + }) + + expect(contributed(await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext))[0]!.text).toBe('call 1 for alice') + expect(contributed(await ctx.systemPrompt.assemble())[0]!.text).toBe('call 2 for nobody') }) it('removes contributions when the contributing fiber is disposed (HMR safety)', async () => { @@ -25,13 +85,30 @@ describe('SystemPrompt', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' }) inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }]) + inner.systemPrompt.variable('scoped_var', () => 'v') }, { inject: ['systemPrompt'] })) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1) + const before = await ctx.systemPrompt.assemble() + expect(contributed(before)).toHaveLength(1) + expect(before.variables).toEqual({ scoped_var: 'v' }) await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections).toHaveLength(0) + expect(contributed(assembly)).toHaveLength(0) + // The built-ins belong to the service fiber, so they survive the plugin's disposal. + expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN) expect(assembly.tools).toHaveLength(0) + expect(assembly.variables).toEqual({}) + }) + + it('rejects a duplicate section name (a double-loaded plugin must fail, not double its text)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'dup', order: 0, text: 'first' }) + expect(() => ctx.systemPrompt.section({ name: 'dup', order: 1, text: 'second' })) + .toThrow('prompt section "dup" is already registered') + // The failed registration leaked nothing; the original stays intact. + const assembly = await ctx.systemPrompt.assemble() + expect(contributed(assembly).map(s => s.text)).toEqual(['first']) }) it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => { @@ -47,12 +124,12 @@ describe('SystemPrompt', () => { }) expect(() => ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' })).toThrow('boom change listener') - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) // nothing leaked + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) // nothing leaked // Subsequent listener-free register contributes exactly once. off() ctx.systemPrompt.section({ name: 'p', order: 0, text: 'persona' }) - expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['p']) + expect(contributed(await ctx.systemPrompt.assemble()).map(s => s.name)).toEqual(['p']) }) it('rolls back a tool provider when a system-prompt/change listener throws (P1-1)', async () => { @@ -72,26 +149,47 @@ describe('SystemPrompt', () => { expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t']) }) - it('composes multiple system-prompt/assemble waterfall listeners in order', async () => { + it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + + let threw = false + const off = ctx.on('system-prompt/change', () => { + if (!threw) { threw = true; throw new Error('boom change listener') } + }) + + expect(() => ctx.systemPrompt.variable('v', () => 'x')).toThrow('boom change listener') + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) // nothing leaked + + off() + ctx.systemPrompt.variable('v', () => 'x') + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ v: 'x' }) + }) + + it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' }) // Listener A appends a section, then delegates. - ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => { + const contexts: AssembleContext[] = [] + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => { + contexts.push(context) assembly.sections.push({ name: 'from-a', order: 100, text: 'a' }) return next() }) // Listener B (registered later, runs after A) sees A's contribution. const seen: string[][] = [] - ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, next) => { + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => { seen.push(assembly.sections.map(s => s.name)) return next() }) - const assembly = await ctx.systemPrompt.assemble() - expect(seen).toEqual([['base', 'from-a']]) - expect(assembly.sections.map(s => s.name)).toEqual(['base', 'from-a']) + const passed: AssembleContext = {} + const assembly = await ctx.systemPrompt.assemble(passed) + expect(seen).toEqual([['harness:identity', 'deployment:persona', 'base', 'from-a']]) + expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'base', 'from-a']) + expect(contexts[0]).toBe(passed) // the caller's context reaches listeners }) it('lets a waterfall listener short-circuit by not calling next()', async () => { @@ -100,7 +198,7 @@ describe('SystemPrompt', () => { ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' }) ctx.on('system-prompt/assemble', async () => { - return { sections: [], tools: [] } satisfies PromptAssembly + return { sections: [], tools: [], variables: {} } satisfies PromptAssembly }) const assembly = await ctx.systemPrompt.assemble() @@ -115,40 +213,33 @@ describe('SystemPrompt', () => { const first = await ctx.systemPrompt.assemble() first.sections[0]!.name = 'mutated' + first.sections[0]!.text = 'mutated' first.tools[0]!.description = 'mutated' const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> } firstParameters.properties['leak'] = { type: 'string' } const second = await ctx.systemPrompt.assemble() - expect(second.sections.map(section => section.name)).toEqual(['base']) + expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base']) + expect(second.sections[0]!.text).toBe(IDENTITY) expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }]) }) it('filters out empty section text from renderPrompt', () => { - // Direct test of renderPrompt: function returning empty string, and empty static text const result = renderPrompt({ sections: [ - { name: 'empty-fn', order: 0, text: () => '' }, + { name: 'empty', order: 0, text: '' }, { name: 'real', order: 1, text: 'content' }, - { name: 'empty-static', order: 2, text: '' }, ], tools: [], + variables: {}, }) expect(result).toBe('content') }) - it('evaluates dynamic function-text sections at each renderPrompt call', () => { - let counter = 0 - const section: PromptSection = { name: 'dynamic', order: 0, text: () => `call ${++counter}` } - expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 1') - expect(renderPrompt({ sections: [section], tools: [] })).toBe('call 2') - }) - it('emits system-prompt/change when a tool provider is registered and disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - const changes: number = 0 let changeCount = 0 ctx.on('system-prompt/change', () => void changeCount++) @@ -159,7 +250,6 @@ describe('SystemPrompt', () => { dispose() // disposal emits change again expect(changeCount).toBe(2) - void changes // silence unused }) it('cleans up tool providers on fiber dispose', async () => { @@ -180,10 +270,10 @@ describe('SystemPrompt', () => { await ctx.plugin(SystemPrompt) const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' }) - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(1) + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1) dispose() - expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) + expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) }) it('removes tool provider when returned disposer is called directly', async () => { @@ -196,4 +286,133 @@ describe('SystemPrompt', () => { dispose() expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) }) + + describe('prompt variables', () => { + it('resolves each variable against the assemble context and emits change on register/unregister', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + let changeCount = 0 + ctx.on('system-prompt/change', () => void changeCount++) + + const dispose = ctx.systemPrompt.variable('who', context => (context as { who?: string }).who) + expect(changeCount).toBe(1) + + expect((await ctx.systemPrompt.assemble({ who: 'alice' } as AssembleContext)).variables).toEqual({ who: 'alice' }) + // A provider returning undefined records "registered but no value here". + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined }) + + dispose() + expect(changeCount).toBe(2) + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + }) + + it('rejects a duplicate variable name and an unreferenceable name', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.variable('model', () => 'm1') + expect(() => ctx.systemPrompt.variable('model', () => 'm2')) + .toThrow('prompt variable "model" is already registered') + expect(() => ctx.systemPrompt.variable('Not Valid', () => 'x')) + .toThrow('invalid prompt variable name "Not Valid"') + // Neither failed registration leaked. + expect((await ctx.systemPrompt.assemble()).variables).toEqual({ model: 'm1' }) + }) + + it('interpolates {{name}} references in section text at render — the persona included', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: 'You run on {{model}} in {{cwd}}.' }) + ctx.systemPrompt.variable('model', () => 'deepseek-v4') + ctx.systemPrompt.variable('cwd', () => '/work') + + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`) + }) + + it('lets a waterfall listener add or override variables before render', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 's', order: 0, text: '{{extra}}' }) + ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, _context, next) => { + assembly.variables['extra'] = 'from-waterfall' + return next() + }) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nfrom-waterfall`) + }) + + it('throws on a reference to an unregistered variable, listing what exists', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'on {{modle}}' }) + ctx.systemPrompt.variable('model', () => 'm') + await expect(async () => renderPrompt(await ctx.systemPrompt.assemble())) + .rejects.toThrow('unknown prompt variable "{{modle}}" in section "persona"; registered variables: model') + }) + + it('names "(none)" when no variables are registered at all', () => { + expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} })) + .toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)') + }) + + it('throws when a referenced variable has no value for this assembly', () => { + expect(() => renderPrompt({ + sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }], + tools: [], + variables: { cwd: undefined }, + })).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")') + }) + + it('throws on a malformed complete reference, e.g. inner spaces', () => { + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text: 'on {{ model }}' }], + tools: [], + variables: { model: 'm' }, + })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"') + }) + + it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => { + const text = renderPrompt({ + sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }], + tools: [], + variables: {}, + }) + expect(text).toBe('shell ${X:-{{fallback} stays') + }) + + it.each([ + { text: '{{{model}}}', label: 'extra outer braces' }, + { text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' }, + ])('throws on a mangled reference with a }} still following ($label)', ({ text }) => { + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text }], + tools: [], + variables: { model: 'm' }, + })).toThrow('malformed prompt variable reference at') + }) + + it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => { + // `in` would find Object.prototype.constructor and splice function + // source into the prompt; Object.hasOwn must reject it instead. + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }], + tools: [], + variables: { model: 'm' }, + })).toThrow('unknown prompt variable "{{constructor}}"') + }) + + it('a variable NAMED like a prototype property works once actually registered', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' }) + ctx.systemPrompt.variable('constructor', () => 'own-value') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nown-value`) + }) + + it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => { + const text = renderPrompt({ + sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }], + tools: [], + variables: { model: 'literal {{sneaky}} inside' }, + }) + expect(text).toBe('v = literal {{sneaky}} inside!') + }) + }) }) diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 9f687793d7..e9de391ba1 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../llm/llm" } diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 102645ad82..65039aea58 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -1,6 +1,6 @@ # dsh-tools -Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall. +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context). ## Service: `ToolRegistry` (ctx key: `tools`) @@ -9,7 +9,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e - `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber. - `ctx.tools.get(name: string): ToolDefinition | undefined` - `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). -- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall. +- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. ### Injected services @@ -19,20 +19,23 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e | Event | Mode | Purpose | |---|---|---| -| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) | +| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` | +| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` | | `tools/change` | emit | A tool was registered or unregistered | ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). +- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. -- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). -- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). +- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. +- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands. +- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. +- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). ### Extension points - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. -- The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto). +- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. ### Typed tool parameter schemas @@ -70,12 +73,18 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an ### Tool-owned UI presentation -A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods: +A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`): -- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`). -- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`. +- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of: + - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`). + - `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card. + - `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`. +- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of: + - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. + - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). + - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff). -Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation. +Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. ```ts import { defineTool } from '@deepseek-ai/dsh-tools' @@ -90,13 +99,13 @@ const bash = defineTool({ async execute(args) { return [{ type: 'text', text: `ran: ${args.command}` }] }, - // The command is the readable title; the description rides as a content block. - presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }), - // Wrap the output as a console block for the UI (not in the model-facing result). + // A terminal card: the command is the title, the description renders above it. + presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }), + // A terminal result: the raw output + exit; the bridge derives the fenced fallback. presentResult: (_args, result) => { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined - return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] } + return { card: 'terminal', output: block.text } }, }) ``` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5a17aa2b0c..dd0ed918db 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,8 +1,9 @@ /** - * Tool registry and execution waterfall. Plugins register tools; the registry + * Tool registry and execution pipeline. Plugins register tools; the registry * feeds schemas into the system prompt, and `execute()` dispatches each call - * through the `tools/execute` waterfall for sandbox, permission, and hook - * plugins to wrap or veto. + * through `tools/pre-execute` (the allow/deny gate) → core dispatch → + * `tools/post-execute` (inspect/replace the result, attach context) for + * sandbox, permission, and hook plugins to gate or transform a call. * * @module @deepseek-ai/dsh-tools */ @@ -10,8 +11,9 @@ import { Context, Service } from 'cordis' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' +import type { ToolCallView, ToolResultView } from './presentation.ts' export { defineTool, @@ -26,6 +28,23 @@ export { type JsonSchemaObject, } from './schema.ts' +// The render-intent vocabulary a tool declares via `presentCall`/`presentResult` +// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` +// stays the single public surface for consumers (producers + the ACP bridge). +export type { + ToolCallKind, + FileLocation, + FileDiff, + ToolCallView, + GenericCallView, + TerminalCallView, + DiffCallView, + ToolResultView, + GenericResultView, + TerminalResultView, + DiffResultView, +} from './presentation.ts' + declare module 'cordis' { interface Context { tools: ToolRegistry @@ -33,14 +52,34 @@ declare module 'cordis' { interface Events { /** - * 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 {@link ToolExecutionResult} without calling - * `next()` to short-circuit (veto). + * Waterfall BEFORE a tool runs — the gate where sandbox, permission, and + * hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners + * receive `(exec, next)`: call `next()` to delegate to the default (allow), + * or return a {@link PreToolDecision} without calling `next()` to + * short-circuit. A `deny` skips dispatch and yields an `isError` result; the + * tool body never runs. Input rewrite is deliberately NOT offered here (see + * {@link PreToolDecision}); `ask` degrades to deny until the permission + * system lands (`FIXME(permissions)`). + * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ - 'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> + 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision> + /** + * Waterfall AFTER a tool runs — where hook plugins inspect the result and + * accept it (optionally REPLACING the model-facing content, and/or attaching + * `additionalContext` for the next request) or block it with corrective + * `feedback` (Claude Code's `PostToolUse`). Listeners receive + * `(exec, result, next)`: call `next()` to delegate to the default (accept + * unchanged), or return a {@link PostToolDecision} to override. The core tool + * dispatch sits between the two waterfalls as plain code, all inside + * `execute`'s outer try/catch (and the tool body keeps its own inner + * try/catch, so a thrown tool still reaches `post-execute` as an `isError` + * result). + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ + 'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision> /** * A tool was registered or unregistered (the available tool set changed). * @mode emit @@ -55,147 +94,37 @@ declare module 'cordis' { // executes sequentially). /** - * Category of a tool call, used by a UI to pick an icon / treatment. A neutral - * vocabulary owned here (NOT an ACP type) so tools describe themselves without - * depending on any client protocol; a UI bridge maps it to its own enum. The - * member set mirrors the common ACP `ToolKind` values; `other` is the default. + * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the + * common case (model-facing content only); the object form additionally attaches + * a tool-private `meta` presentation payload that the registry threads onto the + * `tool/result` session event and hands back to the tool's `presentResult`. + * `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape), + * and MUST be JSON-serializable: it persists on the durable log (the session + * enforces this at `append`), so replay reproduces the card. */ -export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' - -// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation / -// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/ -// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/ -// output/exit) and the split of responsibility is now muddy: the call vs result -// terminal fields overlap, the bridge has to reconcile a `content` block AND a -// `terminal` block AND `rawInput` per call, and the "pending vs completed" -// boundary doesn't cleanly map to how editors actually render (terminal card, -// diff, generic card). Before more tools/UIs depend on this, redesign the type -// so a tool declares its render INTENT once (e.g. a tagged union over card -// kinds) rather than a bag of optional fields the bridge stitches together. -// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together. - -/** - * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, - * a CLI log line) BEFORE the result is known — the *pending* state. Provider- - * neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI - * plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its - * own presentation — the UI must not special-case tool names. - */ -export interface ToolCallPresentation { - /** - * Human-readable, always-visible label describing what THIS call does (e.g. - * the model-written one-line summary of a bash command). Keep it short — a UI - * shows it as a card header / log line. Required: a presentation must have a - * title (a UI falls back to the tool name only when `presentCall` is absent). - */ - title: string - /** Category for icon/treatment; defaults to `other` when omitted. */ - kind?: ToolCallKind - /** - * The salient input to surface in a detail/expanded view — e.g. the bash - * COMMAND itself (as a string), so the title can stay a readable summary - * while the exact command is still visible. Omit to show nothing; a string is - * rendered as-is, an object as pretty JSON. NOT the full raw args object - * unless that is genuinely what a reader wants. - */ - rawInput?: unknown - /** - * UI-facing content to show on the PENDING call alongside the title/card — - * harness {@link ContentBlock}s, in render order. A terminal tool uses this to - * surface its human-readable `description` as a text block ABOVE the terminal - * card (the card itself is requested via {@link terminal} and labelled by the - * command in `title`), since the card has no description slot. Omit to show no - * extra content. A UI maps these to its own content blocks and renders a - * {@link terminal} block (if any) as a terminal card. - */ - content?: ContentBlock[] - /** - * Ask a capable UI to render this call as a TERMINAL (a command running in a - * working directory), not a generic tool card — set by a tool whose call IS a - * shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its - * own terminal affordance and a UI that can't falls back to the normal card. - * Pair with {@link ToolResultPresentation.terminal} for the output/exit. - */ - terminal?: ToolTerminal -} - -/** - * A request to render a tool call as a terminal. The pending presentation - * supplies the working directory; the result presentation (see - * {@link ToolResultPresentation.terminal}) supplies the captured output and exit - * status. Provider-neutral — no client-protocol types. A UI that supports - * terminals shows a cwd-headed terminal card with the command, its output, and - * an exit-status pill; a UI that does not ignores this and renders the ordinary - * card/content. - */ -export interface ToolTerminal { - /** - * Working directory the command ran in, shown as the terminal header. An - * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge - * against the session workspace (the pure tool presenter can't see the - * session cwd). Omit entirely to let the bridge use the session workspace. - */ - cwd?: string - /** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */ - output?: string - /** - * Process exit code, when the run ended by exiting (not a signal). Result-state - * only; lets a capable UI show an exit-status pill on the terminal card. Omit - * when the command was killed by a signal or the exit code is unknown. - */ - exitCode?: number - /** - * Signal name that killed the process (e.g. `SIGTERM`), when it died by signal - * rather than exiting. Result-state only; mutually exclusive with `exitCode`. - */ - signal?: string -} - -/** - * How a tool wants the COMPLETED call shown — the *result* state, after - * `execute` returns. Lets the tool reformat its result for a UI distinctly from - * the model-facing text it returned from `execute` (e.g. wrap command output in - * a fenced ```console block for monospace rendering, which the model-facing - * result must NOT carry). All fields optional: a UI keeps the pending-state - * title and renders the raw result content for anything left unset. - */ -export interface ToolResultPresentation { - /** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */ - title?: string - /** - * UI-facing result content (harness {@link ContentBlock}s), reformatted from - * the model-facing result. Omit to let the UI render the raw result content. - * Stays in harness vocabulary; the UI maps these to its own content blocks. - */ - content?: ContentBlock[] - /** - * Terminal output/exit for a call the pending presentation marked as a - * terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders - * `output` in the terminal card and shows the exit status; an incapable UI - * uses `content` (the tool should supply a text fallback there too). - */ - terminal?: ToolTerminal -} +export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> + execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> /** - * 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`. + * 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). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) 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 + presentCall?(args: unknown): ToolCallView | 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. + * `result` (`execute`'s content + whether it errored). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) 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 + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined } /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ @@ -204,9 +133,16 @@ export interface ToolResult { content: ContentBlock[] /** Whether the call failed. */ isError: boolean + /** + * The tool-private presentation payload the tool attached from `execute` (via + * the object return form), threaded verbatim from the `tool/result` event. + * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when + * the tool attached none. + */ + meta?: unknown } -/** One pending tool call, as it flows through the execution waterfall. */ +/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */ export interface ToolExecution { callId: CallId name: string @@ -247,8 +183,62 @@ export interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo + /** + /** + * Extra model-facing context a `tools/post-execute` listener attached for the + * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part + * of this call's `content` — `content`/`feedback` shape the tool RESULT, but + * `additionalContext` is a SEPARATE `context/message`. A step can carry + * multiple tool calls, so the loop BUFFERS every call's `additionalContext` + * and appends them only AFTER all `tool/result`s for the step, keeping + * tool-call/result adjacency intact. Carried on the result purely to ferry it + * from `execute()` up to the loop's per-step buffer. + */ + additionalContext?: HookContext + /** + * The tool-private presentation payload from a successful `execute` (the object + * return form). Threaded onto the `tool/result` session event and back into + * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the + * tool attached none or the call failed. + */ + meta?: unknown } +/** + * The decision a `tools/pre-execute` listener returns for one pending call. + * Maps onto Claude Code's `PreToolUse` `permissionDecision`. + * + * - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` — + * is deliberately NOT offered: `tool/call` and `assistant/message` are logged + * BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash` + * presentation, read the pre-execution arguments, so an execution-only rewrite + * would desync the UI from what RAN. That consistency redesign is its own + * `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.) + * - `deny` skips dispatch; the loop records an `isError` result carrying `reason`. + * - `ask` is the permission-prompt intent; until the permission system exists it + * degrades to `deny` (`FIXME(permissions)`). + */ +export type PreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string } + | { kind: 'ask'; reason?: string } + +/** + * The decision a `tools/post-execute` listener returns for one finished call. + * Maps onto Claude Code's `PostToolUse` decision. + * + * - `accept` keeps the call successful; optional `content` REPLACES the + * model-facing result (clean: `tool/result` is logged AFTER `execute()` + * returns, so a replaced result is the single source of truth for both derived + * history and UI). Optional `additionalContext` rides to the next request. + * - `block` turns the call into an `isError` result whose content is the + * corrective `feedback` (the model is told the call was rejected and why), + * optionally also attaching `additionalContext`. + */ +export type PostToolDecision = + | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } + /** * Best-effort human-readable message from an arbitrary thrown value: Error * instances use `.message`; non-Error objects with a string `message` @@ -271,8 +261,9 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { /** * 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. + * loop executes calls through the `tools/pre-execute` → dispatch → + * `tools/post-execute` pipeline. The registry contributes its schemas into the + * system-prompt assembly. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] @@ -289,6 +280,9 @@ export class ToolRegistry extends Service { * registered. The tool's schema (minus the `execute` function) is * automatically contributed to the system-prompt assembly. Disposed * with the calling fiber. Emits `tools/change` on register/unregister. + * @param definition - the tool's schema plus its execute (and optional + * presentation) functions. + * @returns the disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { const dispose = this.ctx.effect(function* (this: ToolRegistry) { @@ -312,55 +306,144 @@ export class ToolRegistry extends Service { return () => void dispose() } + /** + * Look up a registered tool. + * @param name - the tool name as registered. + * @returns the definition, or undefined when no tool has that name. + */ get(name: string): ToolDefinition | undefined { return this.store.get(name) } /** * Return all registered tool schemas — exactly the model-facing fields - * (`name`, `description`, `parameters`, and `strict` when set), as sent to the - * model via the system-prompt assembly. Constructed EXPLICITLY rather than by - * stripping known non-schema members: a `ToolDefinition` also carries - * `execute` and the optional `presentCall`/`presentResult` UI callbacks, and - * those (especially the functions) must never leak into a model request. An - * allowlist can't drift when a new non-schema member is added to the - * definition; a denylist (rest-destructure) would silently leak it. + * (`name`, `description`, `parameters`), as sent to the model via the + * system-prompt assembly. Constructed EXPLICITLY rather than by stripping + * known non-schema members: a `ToolDefinition` also carries `execute` and the + * optional `presentCall`/`presentResult` UI callbacks, and those (especially + * the functions) must never leak into a model request. An allowlist can't + * drift when a new non-schema member is added to the definition; a denylist + * (rest-destructure) would silently leak it. + * @returns one deep-cloned schema per registered tool, in registration order. */ schemas(): ToolSchema[] { - return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({ + return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({ name, description, parameters: structuredClone(parameters), - ...strict !== undefined ? { strict } : {}, })) } /** - * Execute one tool call through the `tools/execute` waterfall. If the tool is - * not registered, the result is an `isError` carrying a `UNKNOWN_TOOL` - * structured error. If the tool or a waterfall listener throws, the error is - * caught and returned as an `isError` result so the loop records a failed tool - * call instead of failing the whole turn; a thrown {@link HarnessError} + * Execute one tool call through the `tools/pre-execute` → dispatch → + * `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny) + * and the inspect/transform seam; core dispatch sits between them as plain + * code. The whole thing is wrapped in one outer try/catch so a throwing + * listener (in either waterfall) becomes an `isError` result instead of + * failing the turn; the tool body ALSO keeps its own inner try/catch, so a + * thrown tool becomes an `isError` result that `post-execute` listeners can + * still inspect. If the tool is not registered, the result is an `isError` + * carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError} * surfaces its `{ name, code }` on the result. + * @param exec - the call to run (name, parsed arguments, caller agent, signal). + * @returns the final result after both waterfalls; failures resolve as + * `isError` results, never rejections. */ async execute(exec: ToolExecution): Promise<ToolExecutionResult> { try { - return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => { - try { - const tool = this.store.get(exec.name) - // Unknown tool routes through the same catch as a tool-thrown error, so - // both failure classes get structured `{ name, code }` from one path. - if (!tool) throw new ToolNotFoundError(exec.name) - const content = await tool.execute(exec.arguments, exec) - return { callId: exec.callId, content, isError: false } - } catch (error: unknown) { - return toolErrorResult(exec.callId, error) + // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny + // until the permission system lands) skips dispatch entirely. --- + const decision = await this.ctx.waterfall( + this, 'tools/pre-execute', exec, + () => Promise.resolve<PreToolDecision>({ kind: 'allow' }), + ) + if (decision.kind !== 'allow') { + // deny → isError. ask has no permission UI yet, so degrade to deny + // (FIXME(permissions)): a forthcoming permission system turns `ask` into + // a real prompt; today it is the conservative "not allowed". + const reason = decision.kind === 'deny' + ? decision.reason + : decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)` + const denied: ToolExecutionResult = { + callId: exec.callId, + content: [{ type: 'text', text: `Error: ${reason}` }], + isError: true, } - }) + return await this.postExecute(exec, denied) + } + + // --- Core dispatch (plain code between the waterfalls). The tool body's + // own try/catch turns a throw into an isError result so post-execute can + // inspect it; an unknown tool routes through the same catch. --- + let result: ToolExecutionResult + try { + const tool = this.store.get(exec.name) + if (!tool) throw new ToolNotFoundError(exec.name) + // Normalize the two `execute` return shapes: a bare ContentBlock[] (no + // meta) or a { content, meta } object (a tool attaching a private + // presentation payload). An array IS the content; the object carries it. + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } + } catch (error: unknown) { + result = toolErrorResult(exec.callId, error) + } + + return await this.postExecute(exec, result) } catch (error: unknown) { + // Outer backstop: a throwing pre/post-execute listener (or the waterfall + // machinery) becomes an isError result, never a turn failure. return toolErrorResult(exec.callId, error) } } + + /** + * Run the `tools/post-execute` waterfall over a dispatched `result` and apply + * its {@link PostToolDecision}: `accept` keeps the call successful (replacing + * `content` when given), `block` turns it into an `isError` whose content is + * the corrective `feedback`. Either decision may attach `additionalContext`, + * which is ferried on the returned result for the loop's per-step buffer. + * Runs inside `execute`'s outer try/catch (a throwing listener → isError). + */ + private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> { + // Snapshot the protected outcome BEFORE the waterfall. A listener receives + // the same `result` reference, so a post-waterfall read of `result.callId`/ + // `.isError`/`.error` could carry a listener's mutation — violating the + // authoritative-call-id requirement and the "preserve the dispatched + // isError/error" contract. The decision is the ONLY sanctioned channel for a + // listener to change the outcome (block, or accept-with-replacement); the + // call id is always the authoritative `exec.callId`. `content` is copied into + // a fresh array so a listener's in-place `push`/`splice` on `result.content` + // cannot leak into the returned content either (the elements are the same + // references — the snapshot guards the array structure, not deep immutability). + const dispatched = { + callId: exec.callId, + content: [...result.content], + isError: result.isError, + ...result.error ? { error: result.error } : {}, + ...result.meta !== undefined ? { meta: result.meta } : {}, + } + const decision = await this.ctx.waterfall( + this, 'tools/post-execute', exec, result, + () => Promise.resolve<PostToolDecision>({ kind: 'accept' }), + ) + const additionalContext = decision.additionalContext + if (decision.kind === 'block') { + return { + callId: dispatched.callId, + content: decision.feedback, + isError: true, + ...additionalContext ? { additionalContext } : {}, + } + } + // accept: replace content if supplied, preserve the dispatched isError/error. + return { + ...dispatched, + ...decision.content ? { content: decision.content } : {}, + ...additionalContext ? { additionalContext } : {}, + } + } } function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult { diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts new file mode 100644 index 0000000000..b99fa08ebd --- /dev/null +++ b/packages/core/tools/src/presentation.ts @@ -0,0 +1,206 @@ +/** + * Tool render-intent vocabulary: the provider-neutral types a tool declares via + * `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say + * how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log + * line). A UI bridge switches on the `card` tag to map each intent to its own + * wire shape, so a UI never special-cases tool names. + * + * This is the UI-facing surface of `dsh-tools`, kept separate from the registry + * and execution core in `index.ts`: this module owns ONLY presentation + * vocabulary and references none of the execution types, so the dependency runs + * one way (`index.ts` imports these views for the `ToolDefinition` method + * signatures). The opaque `meta` presentation channel is execution plumbing and + * lives with the registry in `index.ts`, not here. + * + * See the render-intent-union RFC + * (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + * + * @module @deepseek-ai/dsh-tools/src/presentation + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** + * Category of a tool call, used by a UI to pick an icon / treatment. A neutral + * vocabulary owned here (NOT an ACP type) so tools describe themselves without + * depending on any client protocol; a UI bridge maps it to its own enum. The + * member set mirrors the common ACP `ToolKind` values; `other` is the default. + */ +export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' + +/** + * A file location a tool reads or modifies, so a capable UI can "follow along" — + * highlight or jump to the file (and line) as the tool runs. Provider-neutral; + * a UI bridge maps it to its own affordance (the ACP bridge forwards it as + * `tool_call.locations`). `path` is what the tool operated on (the model-facing + * path); `line` is an optional 1-based line to focus (e.g. a read's offset). + */ +export interface FileLocation { + path: string + line?: number +} + +/** + * A single-file change a tool is about to make, for a UI that renders inline + * diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as + * a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a + * new-file create (nothing to diff against); an overwrite also uses `null`, + * because a call-time presenter has no access to the file's prior content. + */ +export interface FileDiff { + path: string + /** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */ + oldText: string | null + /** Content after the change. */ + newText: string +} + +/** + * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a + * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged + * discriminated union: a tool declares its render INTENT once and a UI bridge + * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — + * the tool owns its presentation, so a UI never special-cases tool names. + * + * Returned by `ToolDefinition.presentCall`. See the render-intent-union + * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + */ +export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView + +/** + * The default card: a titled tool-call row with an optional category icon, a + * salient raw input, extra content blocks, and follow-along file locations. Any + * tool whose call is not a terminal or a diff uses this. + */ +export interface GenericCallView { + card: 'generic' + /** + * Human-readable, always-visible label describing what THIS call does. Keep it + * short — a UI shows it as a card header / log line. + */ + title: string + /** Category for icon/treatment; defaults to `other` when omitted. */ + kind?: ToolCallKind + /** + * The salient input to surface in a detail/expanded view (e.g. a background + * task id). Omit to show nothing; a string renders as-is, an object as pretty + * JSON. NOT the full raw args object unless that is genuinely what a reader wants. + */ + rawInput?: unknown + /** + * UI-facing content blocks to show on the pending call alongside the title. + * Omit to show none. A UI maps these to its own content blocks. + */ + content?: ContentBlock[] + /** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */ + locations?: FileLocation[] +} + +/** + * A call that IS a shell command running in a working directory: a capable UI + * renders it as a terminal card (cwd-headed, with the command as the title and + * live/afterward output from the {@link TerminalResultView}); an incapable UI + * falls back to a generic card whose body is the fenced command output. Set by a + * tool whose call is a foreground command (e.g. `bash`). + */ +export interface TerminalCallView { + card: 'terminal' + /** The command, shown as the terminal card's title / header line. */ + title: string + /** + * A human-readable one-line summary of what the command does, rendered ABOVE + * the terminal card (the card itself has no description slot). Omit for none. + */ + description?: string + /** + * Working directory the command runs in, shown as the terminal header. An + * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge + * against the session workspace (the pure presenter can't see the session cwd). + * Omit entirely to let the bridge use the session workspace. + */ + cwd?: string +} + +/** + * A call that creates or modifies files, rendered as an inline diff card by a + * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, + * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is + * `null`); the tool emits a separate {@link DiffResultView} after `execute` — the + * applied change (an edit/overwrite hunk with context, or a whole-file diff for a + * create). + */ +export interface DiffCallView { + card: 'diff' + /** Card header (e.g. `Write foo.txt`). */ + title: string + /** One entry per file the call changes. */ + diffs: FileDiff[] + /** Files this call modifies, for editor follow-along (usually the diffs' paths). */ + locations?: FileLocation[] +} + +/** + * How a tool wants the COMPLETED call shown — the *result* state, after `execute` + * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on + * `card`. Lets the tool reformat its result for a UI distinctly from the + * model-facing text it returned from `execute`. Returned by + * `ToolDefinition.presentResult`; omitting the method keeps the pending + * title and renders the raw result content. + */ +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView + +/** + * The default completed card: an optional replacement title and reformatted + * content. Omit a field to keep the pending title / render the raw result content. + */ +export interface GenericResultView { + card: 'generic' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** + * UI-facing result content (harness {@link ContentBlock}s), reformatted from + * the model-facing result. Omit to let the UI render the raw result content. + */ + content?: ContentBlock[] +} + +/** + * The completed state of a {@link TerminalCallView}: the captured output and exit + * status. A capable UI renders `output` in the terminal card and shows an + * exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE + * derives from `output` (the tool does not double-encode it). + */ +export interface TerminalResultView { + card: 'terminal' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** Captured command output (stdout+stderr as the tool chooses to combine them). */ + output?: string + /** + * Process exit code, when the run ended by exiting (not a signal). Lets a + * capable UI show an exit-status pill. Omit when killed by a signal or unknown. + */ + exitCode?: number + /** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */ + signal?: string +} + +/** + * A completed file mutation rendered as an inline diff card, the *result-time* + * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file + * change (e.g. `write`, `edit`): `diffs` are the change to show — typically the + * APPLIED hunks computed from the before/after content (one entry per hunk, each + * with surrounding context lines), so the editor shows the real change in place; + * a tool with no before-image (e.g. a file create) may instead give a whole-file + * diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's + * content in an editor, so a mutation tool returns this even when it duplicates + * the call-time snippet — otherwise the model-facing result text would replace + * (clobber) the pending diff card. + */ +export interface DiffResultView { + card: 'diff' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ + diffs: FileDiff[] +} diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index b717eabf9a..9149241225 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -19,9 +19,9 @@ * @module dsh-tools/schema */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts' +import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' +import type { ToolCallView, ToolResultView } from './presentation.ts' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type @@ -182,7 +182,7 @@ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject { /** * Thrown by a {@link defineTool} tool when the model-generated arguments don't * match the declared {@link SchemaSpec}. Extends {@link HarnessError} - * (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and + * (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and * returns an `isError` ToolExecutionResult carrying the structured error, so * the model can self-correct and downstream plugins can route on the code. */ @@ -291,27 +291,27 @@ export interface DefineToolOptions<S extends SchemaSpec> { parameters: S /** * Tool execution function. `args` is typed as {@link InferArgs<S>} — zero - * casts needed. + * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing + * content only) or a `{ content, meta }` object to also attach a tool-private + * presentation payload (see {@link ToolExecuteReturn}). */ - execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]> + execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn> /** * Optional: how to present the PENDING state of one call in a UI (an editor * tool-call card, a CLI log line). `args` is the typed, schema-validated * argument shape — zero casts. Pure and side-effect-free: a UI may call it * during live streaming AND a session-log replay, so depend only on `args`. * The tool owns its presentation so a UI never special-cases tool names. See - * {@link ToolCallPresentation}. + * {@link ToolCallView}. */ - presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined + presentCall?(args: InferArgs<S>): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the typed `args` and the * `result`. Use it to reformat result content for a UI distinctly from the * model-facing text (e.g. a fenced ```console block). Pure and side-effect- - * free for the same replay reason. See {@link ToolResultPresentation}. + * free for the same replay reason. See {@link ToolResultView}. */ - presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined - /** Whether the tool requires structured output (default false). */ - strict?: boolean + presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined } /** @@ -353,8 +353,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): name: options.name, description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>, - ...options.strict !== undefined ? { strict: options.strict } : {}, - async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> { + async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an // isError result so the model can self-correct. After this guard, the @@ -369,13 +368,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): // fall back to `undefined` (a generic UI presentation) on any mismatch, rather // than the hard `ToolArgsError` the execute path raises. if (userPresentCall) { - tool.presentCall = (args: unknown): ToolCallPresentation | undefined => { + tool.presentCall = (args: unknown): ToolCallView | undefined => { if (validateArgs(options.parameters, args).length > 0) return undefined return userPresentCall(args as InferArgs<S>) } } if (userPresentResult) { - tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => { + tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { if (validateArgs(options.parameters, args).length > 0) return undefined return userPresentResult(args as InferArgs<S>, result) } diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index eba74c833d..cacc2eef66 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'subagent', 'todo_write']) + expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { @@ -93,25 +93,17 @@ describe('gen-tool-catalog render', () => { { pkg: '@deepseek-ai/dsh-tool-demo', source: 'packages/demo/tool-demo/src/index.ts', + requires: ['ctx.tools'], + writes: ['tool/result'], schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }], }, ] const md = render(catalog) + expect(md).toContain('| `@deepseek-ai/dsh-tool-demo` | `demo` | `ctx.tools` | `tool/result` |') expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`') expect(md).toContain('### `demo`') expect(md).toContain('A demo tool.') expect(md).toContain('```json') expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]') }) - - it('renders the strict flag when a schema sets it', () => { - const catalog: ToolCatalog = [ - { - pkg: '@deepseek-ai/dsh-tool-demo', - source: 'packages/demo/tool-demo/src/index.ts', - schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }], - }, - ] - expect(render(catalog)).toContain('Strict: `true`') - }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index c88963ecc1..09158b8398 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - type InferArgs, type SchemaSpec, type ToolExecutionResult, + type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -52,8 +52,8 @@ describe('ToolRegistry', () => { description: 'has presenters', parameters: { x: { type: 'string', required: true } }, async execute() { return [] }, - presentCall: args => ({ title: args.x }), - presentResult: (args, result) => ({ title: args.x, content: result.content }), + presentCall: args => ({ card: 'generic', title: args.x }), + presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }), })) const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown> expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters']) @@ -62,18 +62,6 @@ describe('ToolRegistry', () => { expect(schema.execute).toBeUndefined() }) - it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => { - const ctx = await setup() - ctx.tools.register(defineTool({ - name: 'strict-tool', - description: 'd', - parameters: { x: { type: 'string', required: true } }, - strict: true, - async execute() { return [] }, - })) - expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true }) - }) - it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -81,6 +69,38 @@ describe('ToolRegistry', () => { expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) }) + it('threads a tool-attached meta (object return form) onto the result', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'meta-tool', + async execute() { + return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } + }, + }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) + expect(result).toEqual({ + callId: CallId('c1'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, + }) + }) + + it('omits meta when the object return form supplies none', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'no-meta-tool', + async execute() { + return { content: [{ type: 'text', text: 'ok' }] } + }, + }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) + expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + expect('meta' in result).toBe(false) + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ @@ -112,53 +132,150 @@ describe('ToolRegistry', () => { expect(err.message).toBe('unknown tool "ghost"') }) - it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => { + it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => { const ctx = await setup() ctx.tools.register(echoTool) - ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => { - if (exec.name === 'echo') { - return { - callId: exec.callId, - content: [{ type: 'text', text: 'denied by policy' }], - isError: true, - } - } + ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { + if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' } return next() }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) - expect(result.content[0]).toMatchObject({ text: 'denied by policy' }) + expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) }) - it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => { + it('an ask decision degrades to deny until the permission system lands', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => + ({ kind: 'ask', reason: 'needs approval' })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' }) + }) + + it('an ask decision with no reason degrades to deny with a default message', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) + }) + + it('a tools/post-execute listener can replace the result content (accept) ', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> => + ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(false) + expect(result.content[0]).toMatchObject({ text: 'rewritten' }) + }) + + it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> => + ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) + }) + + it('a block decision can ALSO attach additionalContext', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> => + ({ + kind: 'block', + feedback: [{ type: 'text', text: 'rejected' }], + additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }, + })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: 'rejected' }) + expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }) + }) + + it('a post-execute additionalContext rides on the result for the loop to buffer', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> => + ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } })) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }) + }) + + it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => { + // The decision is the ONLY sanctioned channel to change the outcome. A + // listener that reaches in and mutates the passed result reference (flipping + // isError, rewriting callId, attaching a bogus error) must NOT affect what + // execute() returns — the registry snapshots the authoritative fields before + // the waterfall and rebuilds from the snapshot + decision. + const ctx = await setup() + ctx.tools.register(echoTool) + + ctx.on('tools/post-execute', async (_exec, result, next) => { + const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] } + mutable.callId = 'hijacked' + mutable.isError = true + mutable.error = { name: 'Evil', code: 'EVIL' } + mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation + return next() // delegate to the default accept — no decision-level override + }) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked' + expect(result.isError).toBe(false) // the real (successful) dispatch outcome + expect(result.error).toBeUndefined() // no listener-injected error + expect(result.content).toHaveLength(1) // the in-place push did not leak in + expect(result.content[0]).toMatchObject({ text: 'hi' }) + expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false) + }) + + it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => { const ctx = await setup() ctx.tools.register(echoTool) const order: string[] = [] - ctx.on('tools/execute', async (_exec, next) => { - order.push('first:before') - const result = await next() - order.push('first:after') - return result + ctx.on('tools/pre-execute', async (_exec, next) => { + order.push('pre:before') + const decision = await next() + order.push('pre:after') + return decision }) - ctx.on('tools/execute', async (_exec, next) => { - order.push('second:before') - const result = await next() - order.push('second:after') - return result + ctx.on('tools/post-execute', async (_exec, _result, next) => { + order.push('post:before') + const decision = await next() + order.push('post:after') + return decision }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) - expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after']) + // pre runs fully (gate) before dispatch, then post runs over the result. + expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after']) }) - it('returns an isError result when a tools/execute listener throws', async () => { + it('returns an isError result when a tools/pre-execute listener throws', async () => { const ctx = await setup() ctx.tools.register(echoTool) - ctx.on('tools/execute', async () => { + ctx.on('tools/pre-execute', async () => { throw new Error('permission hook broke') }) @@ -171,10 +288,26 @@ describe('ToolRegistry', () => { }) }) - it('preserves structured error info when a tools/execute listener throws HarnessError', async () => { + it('returns an isError result when a tools/post-execute listener throws', async () => { const ctx = await setup() ctx.tools.register(echoTool) - ctx.on('tools/execute', async () => { + ctx.on('tools/post-execute', async () => { + throw new Error('post hook broke') + }) + + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + + expect(result).toEqual({ + callId: CallId('c1'), + content: [{ type: 'text', text: 'Error: post hook broke' }], + isError: true, + }) + }) + + it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/pre-execute', async () => { throw new HarnessError('denied', 'DENIED') }) @@ -466,44 +599,6 @@ describe('schema DSL edge cases', () => { }) }) - it('defineTool passes through strict flag when set to true', () => { - const tool = defineTool({ - name: 'strict-tool', - description: 'A strict tool', - parameters: { input: { type: 'string' } }, - strict: true, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(true) - }) - - it('defineTool omits strict when not provided', () => { - const tool = defineTool({ - name: 'non-strict-tool', - description: 'A non-strict tool', - parameters: { input: { type: 'string' } }, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect('strict' in tool).toBe(false) - }) - - it('defineTool strict=false is included', () => { - const tool = defineTool({ - name: 'explicitly-non-strict', - description: 'Explicitly non-strict', - parameters: { input: { type: 'string' } }, - strict: false, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(false) - }) - it('handles enum and default together in one property', () => { const spec = { level: { type: 'string', enum: ['low', 'high'], default: 'low' }, @@ -906,15 +1001,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => { presentCall(args) { // args is typed { path: string; n?: number } — zero casts. expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>() - return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path } + return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path } }, presentResult(args, result) { - return { title: `Opened ${args.path}`, content: result.content } + return { card: 'generic', title: `Opened ${args.path}`, content: result.content } }, }) - expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' }) + expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' }) expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false })) - .toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] }) + .toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] }) }) it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => { @@ -934,8 +1029,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => { description: 'demo', parameters: { path: { type: 'string', required: true } }, async execute() { return [] }, - presentCall: args => ({ title: args.path }), - presentResult: (args, result) => ({ title: args.path, content: result.content }), + presentCall: args => ({ card: 'generic', title: args.path }), + presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }), }) // Unlike execute (which throws ToolArgsError on a mismatch), the display // methods soft-validate and fall back to undefined so a UI never crashes diff --git a/packages/fs/README.md b/packages/fs/README.md new file mode 100644 index 0000000000..985a9f3ad6 --- /dev/null +++ b/packages/fs/README.md @@ -0,0 +1,12 @@ +# fs/ - filesystem capability family + +The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | +| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | + +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md new file mode 100644 index 0000000000..26524fad24 --- /dev/null +++ b/packages/fs/fs-local/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-fs-local + +The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. + +```ts ignore-check +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' + +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) +// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the +// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. +``` + +## Behavior + +- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path. +- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent. +- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. +- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). + +## `cwd` is not a sandbox + +`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Consequences section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences). + +The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json new file mode 100644 index 0000000000..4945684713 --- /dev/null +++ b/packages/fs/fs-local/package.json @@ -0,0 +1,36 @@ +{ + "name": "@deepseek-ai/dsh-fs-local", + "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts new file mode 100644 index 0000000000..b1b9c25397 --- /dev/null +++ b/packages/fs/fs-local/src/fsio.ts @@ -0,0 +1,503 @@ +/** + * Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept + * separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so + * the raw stat/read/write/edit mechanics can be unit-tested without a Context. + * + * This is the PROVIDER layer: it hands back decoded whole-file text (validated + * UTF-8, binary rejected) — never line windows or numbered lines, which are + * model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files + * stream their text in chunks so a huge file never has to be held whole in + * memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here. + * + * Writes are atomic: content goes to a temp file opened exclusively (`wx`, + * `0o600`, so a pre-existing path can never be clobbered and write-in-progress + * bytes stay owner-only) inside a randomly-named private staging directory + * (`0o700`) next to the target, then `rename`d over the target. Edits are + * read-modify-write over the same atomic primitive. + * + * @module @deepseek-ai/dsh-fs-local/fsio + */ + +import { randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' +import type { Dirent, Stats } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { TextDecoder } from 'node:util' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' + +const BINARY_SAMPLE_BYTES = 8192 + +function isENOENT(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +/** + * A path component that is expected to be a directory is a regular file (e.g. + * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target + * cannot exist — so the resolution/probe paths treat it as "absent" rather than + * letting a raw Node error escape without the structured `FsError` taxonomy. + */ +function isENOTDIR(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOTDIR' +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} +/* v8 ignore stop */ + +function isPermissionError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM') +} + +function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { + if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED') +} + +/** + * `readFile` with the supplied signal, translating a mid-read `AbortError` into + * the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted + * `readFile` with a bare `AbortError`, which would otherwise escape the seam's + * error taxonomy — the streaming/write paths translate it the same way). + */ +async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> { + try { + return await readFile(absolutePath, signal ? { signal } : {}) + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */ + if (!isAbortError(error)) throw error + throw new FsError(`${verb} aborted`, 'FS_ABORTED') + } +} + +/** Opaque version token from a stat: mtime (ns precision) + size. */ +function versionOf(info: Stats): FsVersion { + return FsVersion(`${info.mtimeMs}:${info.size}`) +} + +/** + * Test seam: lets specs pin the atomic-write temp names (to prove + * exclusive-open behavior without a name race) and observe the staged temp + * file before it is renamed over the target. + */ +export interface FsIoInternals { + /** Override the generated private staging-dir name (relative to the target dir). */ + tempDirName?: (writePath: string) => string + /** Override the generated temp-file name (relative to the private staging dir). */ + tempName?: (writePath: string) => string + /** Test hook after the temp file is written/synced but before final chmod+rename. */ + inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void> +} + +/** A resolved local path: the absolute path shown to callers and its realpath identity. */ +export interface LocalTarget { + /** Absolute path (symlinks not resolved) — used for display. */ + displayPath: string + /** Realpath identity — used as the stable target key and the I/O path. */ + targetKey: FsTargetKey +} + +/** Result of probing a path: null when it does not exist. */ +export interface PathInfo { + version: FsVersion + mode: number + type: 'file' | 'directory' | 'other' + size: number +} + +/** One local directory child with a resolved target and cheap metadata. */ +export interface LocalDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: LocalTarget + version?: FsVersion + size?: number +} + +/** + * Resolve a path to its absolute display path and realpath identity. Relative + * paths are based on `cwd`. When the file itself does not yet exist, the + * `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends + * the still-missing suffix, so a not-yet-created file gets the same stable key + * it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink + * and intermediate directories are created by the write. Two input paths + * reaching the same file via symlinks share one key. Falls back to the absolute + * path only when no ancestor (not even the filesystem root) can be resolved. + */ +export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> { + if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') + const displayPath = resolve(cwd, path) + try { + // Prefer the file's own realpath (resolves a symlinked file to its target). + return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) } + } catch (error: unknown) { + // A path component is a file, not a directory (e.g. "afile/child.txt" where + // "afile" is a regular file): the target can neither exist nor be created, + // so surface the structured taxonomy instead of a raw Node ENOTDIR. + if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND') + /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */ + if (!isENOENT(error)) throw error + } + // File absent: realpath the nearest existing ancestor and re-append the + // missing suffix (the file basename plus any not-yet-created intermediate + // dirs), so the key is stable across creation of those dirs. + const missing = [basename(displayPath)] + let ancestor = dirname(displayPath) + while (true) { + try { + const realAncestor = await realpath(ancestor) + return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) } + } catch (error: unknown) { + /* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */ + if (!isENOENT(error)) throw error + const parent = dirname(ancestor) + /* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */ + if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) } + missing.unshift(basename(ancestor)) + ancestor = parent + } + } +} + +/** Probe a path for its version, mode, type, and size. Null if absent. */ +export async function probe(absolutePath: string): Promise<PathInfo | null> { + try { + const info = await stat(absolutePath) + const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' + return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } + } catch (error: unknown) { + // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean + // the target is absent; any other stat failure is a real permission/IO fault. + /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error) && !isENOTDIR(error)) throw error + return null + } +} + +// --- Directory listing --- + +function listingIoError(displayPath: string, error: unknown): FsError { + /* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */ + if (error instanceof FsError) return error + /* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */ + if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error }) + if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) + return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) +} + +async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> { + const identity = await resolveLocalTarget(parent.targetKey, name) + return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey } +} + +/** + * List direct children of a directory in stable name order. Each child includes + * a resolved target plus stat metadata when still available; file contents are + * never read. + */ +export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> { + throwIfAborted(signal, 'list') + let info: PathInfo | null + try { + info = await probe(target.targetKey) + } catch (error: unknown) { + throw listingIoError(target.displayPath, error) + } + if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') + + let entries: Dirent[] + try { + entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' }) + } catch (error: unknown) { + /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */ + throw listingIoError(target.displayPath, error) + } + throwIfAborted(signal, 'list') + + const result: LocalDirEntry[] = [] + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + throwIfAborted(signal, 'list') + try { + const childTarget = await resolveListedChildTarget(target, entry.name) + const childInfo = await probe(childTarget.targetKey) + result.push({ + name: entry.name, + type: childInfo?.type ?? 'other', + target: childTarget, + ...(childInfo ? { version: childInfo.version } : {}), + ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), + }) + } catch (error: unknown) { + throw listingIoError(join(target.displayPath, entry.name), error) + } + throwIfAborted(signal, 'list') + } + return result +} + +// --- Reading --- + +function notTextError(verb: 'read' | 'edit', displayPath: string): FsError { + return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT') +} + +function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + throw notTextError(verb, displayPath) + } +} + +function decodeUtf8Stream( + decoder: TextDecoder, + chunk: Uint8Array | undefined, + verb: 'read' | 'edit', + displayPath: string, +): string { + try { + return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode() + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + throw notTextError(verb, displayPath) + } +} + +async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> { + throwIfAborted(signal, verb) + let info: Stats + try { + info = await stat(target.targetKey) + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */ + if (!isENOENT(error)) throw error + throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + return info +} + +/** + * Read a whole regular UTF-8 text file into a single decoded string. Rejects + * non-regular files, invalid UTF-8, and NUL-byte binary samples. + */ +export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> { + await statRegularFile(target, 'read', signal) + const raw = await readFileAbortable(target.targetKey, 'read', signal) + throwIfAborted(signal, 'read') + if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + return decodeUtf8(raw, 'read', target.displayPath) +} + +/** + * Stream a whole regular UTF-8 text file as decoded text chunks. Same text + * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection, + * cross-chunk UTF-8 decoding), but never holds the whole file in memory. + */ +export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> { + await statRegularFile(target, 'read', signal) + const stream = createReadStream(target.targetKey, signal ? { signal } : {}) + const decoder = new TextDecoder('utf-8', { fatal: true }) + let sampledBytes = 0 + + function scanBinarySample(chunk: Buffer): void { + if (sampledBytes >= BINARY_SAMPLE_BYTES) return + const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes)) + if (sample.includes(0)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + sampledBytes += sample.length + } + + try { + for await (const chunk of stream as AsyncIterable<Buffer>) { + scanBinarySample(chunk) + yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath) + } + yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath) + } catch (error: unknown) { + /* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */ + if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') + throw error + } +} + +// --- Writing --- + +async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> { + try { + await rm(stagingDir, { recursive: true, force: true }) + } catch (cleanupError: unknown) { + /* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */ + throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError }) + } + throw originalError +} + +/** + * Atomically write `content` to `absolutePath`: create parent dirs, write to a + * randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private + * (`0o700`) staging directory, fsync, optionally chmod to the final mode while + * still private, then rename over the target. `mode` (when given) preserves an + * existing file's permissions across the replace. + */ +export async function writeFileAtomic( + absolutePath: string, + content: string, + mode: number | undefined, + signal: AbortSignal | undefined, + internals: FsIoInternals = {}, +): Promise<void> { + throwIfAborted(signal, 'write') + const directory = dirname(absolutePath) + await mkdir(directory, { recursive: true }) + + throwIfAborted(signal, 'write') + const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir` + const stagingDir = join(directory, stagingDirName) + const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp` + const tempPath = join(stagingDir, tempName) + let handle: Awaited<ReturnType<typeof open>> | undefined + let stagingCreated = false + try { + await mkdir(stagingDir, { mode: 0o700 }) + stagingCreated = true + await chmod(stagingDir, 0o700) + + handle = await open(tempPath, 'wx', 0o600) + await handle.chmod(0o600) + await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} }) + await handle.sync() + await internals.inspectTemp?.({ stagingDir, tempPath }) + if (mode !== undefined) await handle.chmod(mode) + await handle.close() + handle = undefined + + throwIfAborted(signal, 'write') + await rename(tempPath, absolutePath) + await rm(stagingDir, { recursive: true, force: true }) + } catch (error: unknown) { + /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */ + let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error + /* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */ + if (handle) { + try { + await handle.close() + } catch (closeError: unknown) { + failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure }) + } + } + if (!stagingCreated) throw failure + return removeStagingDirOrThrow(stagingDir, failure) + } +} + +// --- Editing --- + +/** Line ending style detected before LF normalization. */ +export type LineEndings = 'LF' | 'CRLF' + +function normalizeLineEndings(content: string): string { + return content.replaceAll('\r\n', '\n') +} + +function detectLineEndings(raw: string): LineEndings { + const sample = raw.slice(0, 4096) + const crlfCount = sample.split('\r\n').length - 1 + const lfCount = sample.split('\n').length - 1 - crlfCount + return crlfCount > lfCount ? 'CRLF' : 'LF' +} + +function restoreLineEndings(content: string, lineEndings: LineEndings): string { + return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n') +} + +function countOccurrences(content: string, needle: string): number { + let count = 0 + let index = 0 + while (true) { + const found = content.indexOf(needle, index) + if (found === -1) return count + count += 1 + index = found + needle.length + } +} + +/** + * Read and decode a file for editing: rejects binaries, returns LF-normalized + * content plus the original line-ending style for write-back. + */ +export async function readForEdit( + absolutePath: string, + displayPath: string, + signal?: AbortSignal, +): Promise<{ content: string; lineEndings: LineEndings }> { + throwIfAborted(signal, 'edit') + const buffer = await readFileAbortable(absolutePath, 'edit', signal) + throwIfAborted(signal, 'edit') + if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') + const raw = decodeUtf8(buffer, 'edit', displayPath) + return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } +} + +/** + * Best-effort read of a file's current text for a before/after diff basis, used + * by an overwrite. Returns the LF-normalized decoded content, or `null` when the + * file is binary or not valid UTF-8 — a write must succeed regardless of the + * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis + * (the caller treats `null` the same as an absent file: the result renders a + * whole-file diff rather than an applied hunk). + */ +export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> { + const buffer = await readFileAbortable(absolutePath, 'read', signal) + if (buffer.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer)) + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + return null + } +} + +/** + * Apply a literal replacement to LF-normalized content. Throws + * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and + * `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns + * the edited content (still LF-normalized) and the replacement count. + */ +export function applyLiteralEdit( + content: string, + oldString: string, + newString: string, + replaceAll: boolean, + displayPath: string, +): { content: string; replacements: number } { + const oldNorm = normalizeLineEndings(oldString) + if (oldNorm.length === 0) { + throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') + } + const newNorm = normalizeLineEndings(newString) + const replacements = countOccurrences(content, oldNorm) + if (replacements === 0) { + throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND') + } + if (!replaceAll && replacements > 1) { + throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT') + } + return { content: content.split(oldNorm).join(newNorm), replacements } +} + +export { normalizeLineEndings, restoreLineEndings } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts new file mode 100644 index 0000000000..0ad8365d4a --- /dev/null +++ b/packages/fs/fs-local/src/index.ts @@ -0,0 +1,230 @@ +/** + * Local-filesystem implementation of the `ctx.fs` provider seam. + * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven + * text-storage primitives with the host filesystem via + * {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses + * `realpath`, so the stable `targetKey` is the real file identity (two input + * paths reaching the same file through symlinks share one key, and writes land + * on the link target — preserving the link). + * + * Future sandboxed/remote/virtual backends are sibling packages implementing + * the same interface; loading this one populates `ctx.fs`. + * + * @module @deepseek-ai/dsh-fs-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import { + applyLiteralEdit, + listDirectory, + normalizeLineEndings, + probe, + readForEdit, + readTextForDiff, + readWholeText, + resolveLocalTarget, + restoreLineEndings, + streamWholeText, + writeFileAtomic, +} from './fsio.ts' +import type { FsIoInternals } from './fsio.ts' + +export { + applyLiteralEdit, + listDirectory, + probe, + readForEdit, + readTextForDiff, + readWholeText, + resolveLocalTarget, + restoreLineEndings, + streamWholeText, + writeFileAtomic, +} from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts' + +/** Configuration for the local filesystem backend. */ +export interface Config { + /** Base directory for relative paths. Defaults to `process.cwd()`. */ + cwd?: string +} + +type ResolvedConfig = Required<Config> + +/** + * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} + * (a resolution default, NOT a containment boundary — see the filesystem + * capability-seam RFC); enforce + * containment with a stricter backend or a `tools/execute` permission plugin. + */ +export class LocalFileSystem extends FileSystem { + static Config: z<Config> = z.object({ + cwd: z.string().default(process.cwd()), + }) + + readonly config: ResolvedConfig + /** Test seam forwarded to fsio (force streaming path, pin temp names). */ + internals: FsIoInternals = {} + /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write + * window can't interleave, making concurrent writes/edits deterministically + * ordered (one wins, the rest see the new version and reject as stale). */ + private locks = new Map<string, Promise<unknown>>() + + constructor(ctx: Context, config: Config) { + super(ctx) + this.config = config as ResolvedConfig + } + + /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ + private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> { + const prior = this.locks.get(targetKey) ?? Promise.resolve() + const run = prior.then(op, op) + // Keep the chain alive but swallow this op's result/throw for the *next* waiter. + const tail = run.then(() => undefined, () => undefined) + this.locks.set(targetKey, tail) + try { + return await run + } finally { + if (this.locks.get(targetKey) === tail) { + this.locks.delete(targetKey) + } + } + } + + override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> { + const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) + return { targetKey: local.targetKey, displayPath: local.displayPath } + } + + override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> { + if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED') + const info = await probe(target.targetKey) + if (!info) return undefined + return { version: info.version, type: info.type, size: info.size } + } + + override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> { + return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) + } + + override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> { + return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) + } + + override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> { + const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) + return entries.map(entry => ({ + name: entry.name, + type: entry.type, + target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, + ...(entry.version !== undefined ? { version: entry.version } : {}), + ...(entry.size !== undefined ? { size: entry.size } : {}), + })) + } + + override async writeText( + target: FsTarget, + content: string, + expected?: FsWriteIntent, + signal?: AbortSignal, + ): Promise<FsWriteOutcome> { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (existing && existing.type !== 'file') { + throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + + if (expected?.kind === 'replaceIfVersion') { + // Stale guard: the file must still exist at the version the owner observed. + if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') + if (existing.version !== expected.version) { + throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') + } + } else if (expected?.kind === 'createIfAbsent' && existing) { + // createIfAbsent onto an existing file: a blind overwrite — require a read first. + throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED') + } + // expected === undefined: unconditional create-or-overwrite (the bare + // provider) — no version guard, no read-first requirement. Still atomic + // (the per-target lock is unconditional), so the write is never torn. + + // Capture the prior text (the before/after diff basis) BEFORE the write. + // `null` for a create (no existing file) OR an existing-but-undiffable + // file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk + // basis, so a consumer falls back to a whole-file diff (the tool still + // renders a result-time diff card, not the raw result text). + // TODO(overwrite-diff-bound): this reads the whole prior file into memory + // for a UI-only diff; bound the pre-read and fall back to no contextual + // basis above a size threshold (see the applied-hunk-diffs RFC non-goals). + const before = existing ? await readTextForDiff(target.targetKey, signal) : null + await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) + const after = await probe(target.targetKey) + return { + operation: existing ? 'update' : 'create', + version: this.versionAfterWrite(after, target), + before, + // LF-normalized to share the diff basis with `before` (also LF): a CRLF + // overwrite must not read as every line changed. Line-ending restoration + // is a storage detail the applied-hunk diff ignores. + after: normalizeLineEndings(content), + } + }) + } + + override async editText( + target: FsTarget, + edit: FsEditRequest, + expected?: { version: FsVersion }, + signal?: AbortSignal, + ): Promise<FsEditOutcome> { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + // Stale guard BEFORE literal matching: an edit based on an old read reports + // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content. + // A missing target reports FS_STALE_VERSION on BOTH paths (guarded and + // unconditional) — one "cannot edit this target now" code. + if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') + if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + // expected === undefined: unconditional edit of the current content — no + // version guard. Still inside the per-target lock, so the read→match→write + // window is serialized and atomic. + if (expected && existing.version !== expected.version) { + throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') + } + + const original = await readForEdit(target.targetKey, target.displayPath, signal) + const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath) + const content = restoreLineEndings(edited.content, original.lineEndings) + await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals) + + const after = await probe(target.targetKey) + return { + version: this.versionAfterWrite(after, target), + // The LF-normalized before/after text (the applied-hunk diff basis); + // line-ending restoration is a storage detail the diff ignores. + before: original.content, + after: edited.content, + } + }) + } + + /* v8 ignore next 5 -- the post-write probe finding the file absent requires a + * concurrent unlink between rename and stat; fall back to a sentinel version. */ + private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion { + if (after) return after.version + return FsVersion(`missing:${target.targetKey}`) + } +} + +export default LocalFileSystem diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts new file mode 100644 index 0000000000..997021d346 --- /dev/null +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -0,0 +1,507 @@ +/** + * Tests for the local backend through the `ctx.fs` provider seam: stat, whole- + * file/streamed text reads, atomic guarded writes (createIfAbsent / + * replaceIfVersion), version-guarded literal edits, concurrency races, symlink + * identity, and HMR/disposal. Read WINDOWING is policy and lives in + * `dsh-fs-policy`, so it is not exercised here. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget } from '@deepseek-ai/dsh-fs' + +let dir: string +let ctx: Context +let fs: LocalFileSystem +let fiber: Awaited<ReturnType<Context['plugin']>> + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fs-')) + ctx = new Context() + fiber = await ctx.plugin(LocalFileSystem, { cwd: dir }) + fs = ctx.fs as LocalFileSystem +}) +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +function lockCount(localFs: LocalFileSystem): number { + return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size +} + +/** The version the backend currently reports for a resolved target. */ +async function versionOf(target: FsTarget): Promise<FsVersion> { + const info = await fs.stat(target) + if (!info) throw new Error('expected target to exist') + return info.version +} + +describe('registration', () => { + it('registers LocalFileSystem as ctx.fs with a default cwd', async () => { + const bare = new Context() + const bareFiber = await bare.plugin(LocalFileSystem) + expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd()) + await bareFiber.dispose() + }) +}) + +describe('resolve', () => { + it('resolves a relative path against opts.cwd, not config.cwd', async () => { + // config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative + // path there (the per-session-workspace seam — mirrors tool-bash workdir). + const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-')) + try { + await writeFile(join(other, 'x.txt'), 'in other') + const viaOther = await fs.resolve('x.txt', { cwd: other }) + expect(await fs.readText(viaOther)).toBe('in other') + // Same relative path with no opts falls back to config.cwd (= dir), where + // x.txt does not exist. + await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + } finally { + await rm(other, { recursive: true, force: true }) + } + }) + + it('ignores opts.cwd for an ABSOLUTE path', async () => { + await writeFile(join(dir, 'abs.txt'), 'absolute') + const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' }) + expect(await fs.readText(target)).toBe('absolute') + }) +}) + +describe('stat', () => { + it('returns file metadata, directory type, and undefined for absent', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const fileInfo = await fs.stat(await fs.resolve('a.txt')) + expect(fileInfo?.type).toBe('file') + expect(fileInfo?.size).toBe(5) + expect(typeof fileInfo?.version).toBe('string') + + expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory') + expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined() + }) + + it('honors a pre-aborted signal', async () => { + await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + +describe('readText / streamText', () => { + it('reads whole-file text', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree') + expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree') + }) + + it('streams the same text', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree') + const target = await fs.resolve('a.txt') + let streamed = '' + for await (const chunk of await fs.streamText(target)) streamed += chunk + expect(streamed).toBe('one\ntwo\nthree') + }) + + it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => { + await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) +}) + +describe('listDir', () => { + it('lists files and directories in stable name order with resolved child targets', async () => { + await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true }) + await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta') + await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha') + await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link')) + + const entries = await fs.listDir(await fs.resolve('skills')) + expect(entries.map(entry => [entry.name, entry.type])).toEqual([ + ['alpha.md', 'file'], + ['broken-link', 'other'], + ['dir-skill', 'directory'], + ['zeta.md', 'file'], + ]) + expect(entries.map(entry => entry.target.displayPath)).toEqual([ + join(dir, 'skills', 'alpha.md'), + join(dir, 'skills', 'broken-link'), + join(dir, 'skills', 'dir-skill'), + join(dir, 'skills', 'zeta.md'), + ]) + const materializedEntries = entries.filter(entry => entry.version !== undefined) + expect(materializedEntries.map(entry => entry.target.targetKey)) + .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath)))) + expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5) + expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string') + expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined() + expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined() + }) + + it('reports a missing directory as FS_NOT_FOUND', async () => { + await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('reports a file target as FS_NOT_DIRECTORY', async () => { + await writeFile(join(dir, 'a.txt'), 'text') + await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' }) + }) + + it('honors a pre-aborted signal', async () => { + await mkdir(join(dir, 'skills'), { recursive: true }) + await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + +describe('writeText', () => { + it('createIfAbsent creates a new file', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' }) + expect(outcome.operation).toBe('create') + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old') + }) + + it('replaceIfVersion replaces when the version matches', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) }) + expect(outcome.operation).toBe('update') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new') + }) + + it('replaceIfVersion rejects a stale version', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const target = await fs.resolve('a.txt') + const stale = await versionOf(target) + await writeFile(join(dir, 'a.txt'), 'changed-externally') + await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => { + const path = join(dir, 'a.txt') + await writeFile(path, 'v1') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + await unlink(path) + await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects writing onto a directory', async () => { + const target = await fs.resolve('.') + await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('unconditionally creates a new file with no expectation (bare provider)', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.writeText(target, 'fresh') + expect(outcome.operation).toBe('create') + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'clobbered') + expect(outcome.operation).toBe('update') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered') + }) + + it('rejects writing onto a directory even with no expectation', async () => { + const target = await fs.resolve('.') + await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('a create reports before:null and after = the written content (no prior file)', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.writeText(target, 'fresh') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('fresh') + }) + + it('an overwrite reports before = the OLD content and after = the new content', async () => { + await writeFile(join(dir, 'a.txt'), 'old body') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'new body') + expect(outcome.before).toBe('old body') + expect(outcome.after).toBe('new body') + }) + + it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => { + // The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while + // `before` is LF-normalized, a CRLF rewrite would read as every line changed. + // Both sides are LF so only the genuinely-changed line diffs. + await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n') + expect(outcome.before).toBe('a\nb\nc\n') + expect(outcome.after).toBe('a\nB\nc\n') + }) + + it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02])) + const target = await fs.resolve('a.bin') + const outcome = await fs.writeText(target, 'now text') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('now text') + }) + + it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => { + // 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's + // fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file + // still yields a successful write with no before-content basis. + await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69])) + const target = await fs.resolve('a.bin') + const outcome = await fs.writeText(target, 'now valid') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('now valid') + }) + + it('releases per-target mutation locks after success and failure', async () => { + const target = await fs.resolve('a.txt') + await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) + expect(lockCount(fs)).toBe(0) + await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(lockCount(fs)).toBe(0) + }) + + it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const target = await fs.resolve('a.txt') + const before = await versionOf(target) + // Change the byte length so the mtimeMs:size token provably differs (a + // same-size same-tick rewrite can collide — the documented version-token + // limitation; not what this test is about). + const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before }) + expect(outcome.version).not.toBe(before) + expect(outcome.version).toBe(await versionOf(target)) + }) + + it('honors a pre-aborted signal without creating the file', async () => { + const target = await fs.resolve('aborted.txt') + await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort())) + .rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(lockCount(fs)).toBe(0) + }) + + it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + const results = await Promise.allSettled([ + fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }), + fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }), + ]) + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) + const rejected = results.filter(r => r.status === 'rejected') + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('editText', () => { + it('applies a literal edit at the matching version', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const target = await fs.resolve('a.txt') + const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) }) + expect(outcome.after).toBe('hello there') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('reports before/after content (the applied-hunk basis), LF-normalized', async () => { + await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n') + const target = await fs.resolve('a.txt') + const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false }) + expect(outcome.before).toBe('a\nOLD\nb\n') + expect(outcome.after).toBe('a\nNEW\nb\n') + // The written file keeps the original CRLF endings (before/after are the + // LF-normalized diff basis, not the on-disk bytes). + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n') + }) + + it('checks the stale version BEFORE literal matching', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const target = await fs.resolve('a.txt') + const stale = await versionOf(target) + // Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND. + await writeFile(join(dir, 'a.txt'), 'goodbye') + await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('unconditionally edits the current content with no expectation (bare provider)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const target = await fs.resolve('a.txt') + // No version guard: any current content is edited, regardless of version. + const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }) + expect(outcome.after).toBe('hello there') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => { + const target = await fs.resolve('missing.txt') + await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const target = await fs.resolve('a.txt') + await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + }) + + it('rejects a deleted target as stale (before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + await unlink(join(dir, 'a.txt')) + await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects a non-regular target', async () => { + const target = await fs.resolve('.') + await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('rejects zero matches and ambiguous matches at the right version', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + }) + + it('replaces all matches with replaceAll', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + const target = await fs.resolve('a.txt') + const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) }) + expect(outcome.after).toBe('b b b') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') + }) + + it('rejects invalid UTF-8 without rewriting the file', async () => { + const path = join(dir, 'bad.txt') + const bytes = Buffer.from([0x68, 0xff, 0x69]) + await writeFile(path, bytes) + const target = await fs.resolve('bad.txt') + const version = await versionOf(target) + await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(await readFile(path)).toEqual(bytes) + }) + + it('two concurrent edits: one wins, the other is rejected as stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + const results = await Promise.allSettled([ + fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }), + fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }), + ]) + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) + const rejected = results.filter(r => r.status === 'rejected') + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) + + it('honors a pre-aborted signal without rewriting the file', async () => { + await writeFile(join(dir, 'a.txt'), 'keep') + const target = await fs.resolve('a.txt') + await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort())) + .rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep') + expect(lockCount(fs)).toBe(0) + }) + + it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => { + await writeFile(join(dir, 'a.txt'), 'one two') + const target = await fs.resolve('a.txt') + const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) }) + // The version the first edit returned is a valid guard for a second edit — + // no intervening re-stat needed. + const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version }) + expect(second.after).toBe('ONE TWO') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO') + }) + + it('concurrent write vs edit at the same version: one wins, the other is stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const target = await fs.resolve('a.txt') + const version = await versionOf(target) + const results = await Promise.allSettled([ + fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }), + fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }), + ]) + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) + const rejected = results.filter(r => r.status === 'rejected') + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('symlink targetKey identity', () => { + it('two paths to the same file via a symlink share one version and write the real target', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const viaReal = await fs.resolve('real.txt') + const viaLink = await fs.resolve('link.txt') + expect(viaLink.targetKey).toBe(viaReal.targetKey) + + const version = await versionOf(viaReal) + await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved + }) + + it('a stale change is detected across both paths', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const viaReal = await fs.resolve('real.txt') + const stale = await versionOf(viaReal) + await writeFile(join(dir, 'real.txt'), 'changed') + const viaLink = await fs.resolve('link.txt') + await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale })) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) +}) + +describe('HMR / disposal', () => { + it('disposing the fiber withdraws ctx.fs', async () => { + const local = new Context() + const localFiber = await local.plugin(LocalFileSystem, { cwd: dir }) + expect(local.fs).toBeDefined() + await localFiber.dispose() + expect(local.fs).toBeUndefined() + }) +}) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts new file mode 100644 index 0000000000..3a30f73ed2 --- /dev/null +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -0,0 +1,468 @@ +/** + * Cordis-free tests for the raw local-filesystem I/O: path resolution, probe, + * whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp + * safety, literal edit matching, and line-ending handling. Line WINDOWING is + * policy and lives in `dsh-fs-policy`, so it is not tested here. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { + applyLiteralEdit, + listDirectory, + probe, + readForEdit, + readWholeText, + resolveLocalTarget, + restoreLineEndings, + streamWholeText, + writeFileAtomic, +} from '@deepseek-ai/dsh-fs-local' +import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' +import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' + +let dir: string +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) }) + +async function collect(chunks: AsyncIterable<string>): Promise<string> { + let out = '' + for await (const chunk of chunks) out += chunk + return out +} + +describe('resolveLocalTarget', () => { + it('resolves a relative path from cwd and realpaths it', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + const target = await resolveLocalTarget(dir, 'a.txt') + expect(target.displayPath).toBe(file) + expect(target.targetKey).toBe(await realpath(file)) + }) + + it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => { + const target = await resolveLocalTarget(dir, 'missing.txt') + expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt')) + }) + + it('two paths to the same file via a symlink share one targetKey', async () => { + const real = join(dir, 'real.txt') + await writeFile(real, 'hi') + const link = join(dir, 'link.txt') + await symlink(real, link) + const viaReal = await resolveLocalTarget(dir, 'real.txt') + const viaLink = await resolveLocalTarget(dir, 'link.txt') + expect(viaLink.targetKey).toBe(viaReal.targetKey) + expect(viaLink.displayPath).toBe(link) + }) + + it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => { + const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt') + expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt')) + }) + + it('keeps the key stable across create when an ancestor is a symlink', async () => { + // A symlinked workspace root with a not-yet-created subdirectory: the + // pre-create key (via the symlink, missing parent) must equal the + // post-create key (file exists, realpathed) so observed-state survives. + const realRoot = join(dir, 'real-root') + await mkdir(realRoot) + const linkRoot = join(dir, 'link-root') + await symlink(realRoot, linkRoot) + + const before = await resolveLocalTarget(linkRoot, 'sub/file.txt') + await mkdir(join(realRoot, 'sub'), { recursive: true }) + await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path + const after = await resolveLocalTarget(linkRoot, 'sub/file.txt') + expect(before.targetKey).toBe(after.targetKey) + }) + + it('rejects a blank path', async () => { + await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => { + // "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath; + // the raw Node error must be translated into the FsError taxonomy so the tool + // result keeps its { name, code } metadata. + await writeFile(join(dir, 'afile'), 'i am a file') + const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e) + expect(err).toBeInstanceOf(FsError) + expect(err).toMatchObject({ code: 'FS_NOT_FOUND' }) + }) +}) + +describe('probe', () => { + it('returns null for a missing path and metadata for a file', async () => { + expect(await probe(join(dir, 'nope'))).toBeNull() + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + const info = await probe(file) + expect(info?.type).toBe('file') + expect(info?.size).toBe(2) + expect(typeof info?.version).toBe('string') + }) + + it('reports a directory and a non-regular type', async () => { + const sub = join(dir, 'sub') + await mkdir(sub) + expect((await probe(sub))?.type).toBe('directory') + }) + + it('reports a socket/special file as type "other"', async () => { + const sockPath = join(dir, 'sock') + const server = createServer() + try { + await new Promise<void>((resolve, reject) => { + server.once('error', reject) + server.listen(sockPath, () => { resolve() }) + }) + } catch (error: unknown) { + // A restricted sandbox may forbid unix-domain sockets; that is an + // environment limit, not a filesystem regression — skip rather than fail. + const code = (error as NodeJS.ErrnoException).code + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return + throw error + } + try { + expect((await probe(sockPath))?.type).toBe('other') + } finally { + await new Promise<void>((resolve) => { server.close(() => { resolve() }) }) + } + }) + + it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => { + await writeFile(join(dir, 'afile'), 'i am a file') + expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull() + }) +}) + +describe('listDirectory', () => { + it('lists direct children in stable order without reading content', async () => { + const root = join(dir, 'skills') + await mkdir(join(root, 'dir-skill'), { recursive: true }) + await writeFile(join(root, 'zeta.md'), 'zeta') + await writeFile(join(root, 'alpha.md'), 'alpha') + await symlink(join(root, 'missing-target'), join(root, 'broken-link')) + + const entries = await listDirectory(localTarget(root)) + expect(entries.map(entry => [entry.name, entry.type])).toEqual([ + ['alpha.md', 'file'], + ['broken-link', 'other'], + ['dir-skill', 'directory'], + ['zeta.md', 'file'], + ]) + expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5) + expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string') + expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined() + expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined() + }) + + it('derives child target keys from the listed parent identity', async () => { + const realOne = join(dir, 'real-one') + const realTwo = join(dir, 'real-two') + const link = join(dir, 'link') + await mkdir(realOne) + await mkdir(realTwo) + await writeFile(join(realOne, 'same.txt'), 'one') + await writeFile(join(realTwo, 'same.txt'), 'different two') + await symlink(realOne, link) + const target = await resolveLocalTarget(dir, 'link') + + await unlink(link) + await symlink(realTwo, link) + + const entries = await listDirectory(target) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ + name: 'same.txt', + target: { + displayPath: join(link, 'same.txt'), + targetKey: await realpath(join(realOne, 'same.txt')), + }, + size: 3, + }) + }) + + it('rejects missing, non-directory, and aborted listing requests', async () => { + await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' }) + await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('translates directory permission failures into FS_PERMISSION_DENIED', async () => { + const root = join(dir, 'restricted') + await mkdir(root) + await chmod(root, 0o000) + try { + const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught) + // Root-like environments may still be able to list mode-000 directories. + if (error === undefined) return + expect(error).toBeInstanceOf(FsError) + expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' }) + } finally { + await chmod(root, 0o700) + } + }) + + it('translates preflight metadata IO failures into FS_IO_ERROR', async () => { + const loop = join(dir, 'loop') + await symlink(loop, loop) + await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + + it('translates child resolution failures into structured listing errors', async () => { + const root = join(dir, 'listed') + await mkdir(root) + const loop = join(root, 'loop') + await symlink(loop, loop) + await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + + it('translates child permission failures into FS_PERMISSION_DENIED', async () => { + const root = join(dir, 'listed') + const protectedRoot = join(dir, 'protected') + const secret = join(protectedRoot, 'secret') + await mkdir(root) + await mkdir(secret, { recursive: true }) + await symlink(secret, join(root, 'secret-link')) + await chmod(protectedRoot, 0o000) + try { + const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught) + // Root-like environments may still resolve through mode-000 directories. + if (error === undefined) return + expect(error).toBeInstanceOf(FsError) + expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' }) + } finally { + await chmod(protectedRoot, 0o700) + } + }) +}) + +describe('readWholeText', () => { + it('reads a small file', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree') + }) + + it('rejects a missing file and a directory', async () => { + await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('rejects binary and invalid UTF-8', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one') + await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('passes a live (non-aborted) signal through', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo') + }) + + it('translates a mid-read AbortError into FS_ABORTED', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const ac = new AbortController() + // Abort after the synchronous entry check but before readFile runs (the + // stat await yields control back here), so readFile rejects AbortError. + const pending = readWholeText(localTarget(file), ac.signal) + ac.abort() + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + +describe('streamWholeText', () => { + it('streams the whole file as decoded text', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree') + }) + + it('streams a large multi-chunk file correctly', async () => { + const file = join(dir, 'big.txt') + const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n') + await writeFile(file, content) + expect(await collect(streamWholeText(localTarget(file)))).toBe(content) + }) + + it('rejects a missing file, directory, binary, and invalid UTF-8', async () => { + await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one') + await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('passes a live (non-aborted) signal through the stream', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo') + }) + + it('translates a mid-stream abort into FS_ABORTED', async () => { + // A multi-chunk file so the stream yields more than once; abort after the + // first chunk and assert the structured code, not a raw AbortError. + const file = join(dir, 'big.txt') + await writeFile(file, 'x'.repeat(256 * 1024)) + const ac = new AbortController() + const run = async (): Promise<void> => { + let seen = 0 + for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) { + seen += 1 + if (seen === 1) ac.abort() + } + } + await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + +describe('writeFileAtomic — temp-file safety', () => { + it('writes through a private staging dir and owner-only temp file', async () => { + const file = join(dir, 'a.txt') + let inspected = false + await writeFileAtomic(file, 'hello', 0o640, undefined, { + inspectTemp: async ({ stagingDir, tempPath }) => { + inspected = true + expect((await stat(stagingDir)).mode & 0o777).toBe(0o700) + expect((await stat(tempPath)).mode & 0o777).toBe(0o600) + }, + }) + expect(inspected).toBe(true) + expect(await readFile(file, 'utf8')).toBe('hello') + expect((await stat(file)).mode & 0o777).toBe(0o640) + expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) + }) + + it('creates new files owner-only by default', async () => { + const file = join(dir, 'a.txt') + await writeFileAtomic(file, 'hello', undefined, undefined) + expect((await stat(file)).mode & 0o777).toBe(0o600) + }) + + it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => { + const file = join(dir, 'a.txt') + const tempDirName = '.fixed-temp.tmpdir' + await mkdir(join(dir, tempDirName)) + await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep') + await expect( + writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }), + ).rejects.toMatchObject({ code: 'EEXIST' }) + expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep') + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('creates parent directories as needed', async () => { + const file = join(dir, 'nested', 'deep', 'a.txt') + await writeFileAtomic(file, 'hi', undefined, undefined) + expect(await readFile(file, 'utf8')).toBe('hi') + }) + + it('passes a live (non-aborted) signal through the write', async () => { + const file = join(dir, 'a.txt') + await writeFileAtomic(file, 'hi', undefined, new AbortController().signal) + expect(await readFile(file, 'utf8')).toBe('hi') + }) + + it('aborts before writing when the signal is already aborted', async () => { + const file = join(dir, 'a.txt') + await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('cleans up the temp file when the final rename fails', async () => { + const sub = join(dir, 'occupied') + await mkdir(sub) + await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) + expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) + }) +}) + +describe('applyLiteralEdit', () => { + it('replaces a unique match', () => { + expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 }) + }) + + it('rejects zero matches', () => { + expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' })) + }) + + it('rejects an empty oldString without scanning forever', () => { + expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' })) + }) + + it('rejects multiple matches without replaceAll', () => { + expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' })) + }) + + it('replaces all matches with replaceAll', () => { + expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 }) + }) + + it('matches across normalized line endings', () => { + expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1) + }) +}) + +describe('readForEdit + restoreLineEndings', () => { + it('round-trips CRLF: matches on LF, writes back CRLF', async () => { + const file = join(dir, 'crlf.txt') + await writeFile(file, 'one\r\ntwo\r\n') + const original = await readForEdit(file, file) + expect(original.lineEndings).toBe('CRLF') + const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file) + expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n') + }) + + it('rejects a binary file and invalid UTF-8', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01])) + await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('passes a live (non-aborted) signal through the read', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const original = await readForEdit(file, file, new AbortController().signal) + expect(original.content).toBe('one\ntwo') + }) + + it('translates a mid-read AbortError into FS_ABORTED', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const ac = new AbortController() + // Abort after the synchronous entry check, while readFile is pending. + const pending = readForEdit(file, file, ac.signal) + ac.abort() + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json new file mode 100644 index 0000000000..0808fd29ca --- /dev/null +++ b/packages/fs/fs-local/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md new file mode 100644 index 0000000000..ad912bfc95 --- /dev/null +++ b/packages/fs/fs-policy/README.md @@ -0,0 +1,48 @@ +# @deepseek-ai/dsh-fs-policy + +The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. + +```ts +import type { Context } from 'cordis' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' + +declare const ctx: Context + +// No service to inject — this plugin only registers the three fs/* listeners. +// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the +// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin +// decides. Order does not matter for resolution (no inject), but the policy +// listener should be the first decider registered for the fs/*-intent slots. +await ctx.plugin(FsPolicy) +``` + +## The four-layer split + +| Layer | Package | Role | +|---|---|---| +| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | +| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | +| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` | + +## How the gate participates + +Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`): + +| Event | This plugin's listener | +|---|---| +| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | +| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | + +## Observed state is the prior-observation record; freshness is provider CAS + +Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. + +## Single-slot, first-wins + +The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. + +## No method coupling + +Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service. diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json new file mode 100644 index 0000000000..c3f2a07982 --- /dev/null +++ b/packages/fs/fs-policy/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-fs-policy", + "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts new file mode 100644 index 0000000000..4d5c7964b7 --- /dev/null +++ b/packages/fs/fs-policy/src/index.ts @@ -0,0 +1,160 @@ +/** + * The fs-policy PLUGIN: observed-state, read-before-edit, and + * "write/edit must be based on the version you read" — added on top of the + * `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method + * service. This plugin registers NO `ctx.fsPolicy` service and exposes no + * `read`/`write`/`edit`/`resolve` methods; it influences the world only by + * deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and + * recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs` + * (the executor) free of any method coupling to the policy layer — removing + * this plugin gracefully loses the policy and leaves the unconstrained bare + * provider, rather than breaking the tool at a service-injection boundary. + * + * ## Observed state IS the prior-observation record + * + * State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry + * exists iff the owner has read, written, OR edited that target (every success + * emits `fs/observed`), so its presence means "this owner has observed this + * target at this version". This is what lets a create-then-edit or + * edit-then-edit sequence work without an intervening re-read: the mutation + * refreshes the recorded version to its own result. The owner is derived + * structurally from `{ agent?: { session? } }` and held weakly, so a collected + * session frees its state; disposal drops everything (HMR safety). + * + * ## Freshness via provider CAS, not stat + * + * This plugin does NO filesystem I/O. "Have you observed this file?" is a + * `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read + * still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same + * atomic lock that performs the mutation — this plugin only supplies the + * observed version as the CAS basis. Stat-ing and comparing here would open a + * TOCTOU gap the provider lock has to back up anyway, so it is deliberately + * avoided. + * + * ## Single-slot, first-wins + * + * The `fs/write-intent`/`fs/edit-intent` listeners do NOT call + * `next()`: each fully decides its single slot. The slot is first-wins by + * registration order — this plugin owning it is the default-deployment + * convention, not an event-enforced invariant (a decider registered before / + * `prepend`ed would win instead). This is not a composable authorization chain; + * layered permission/audit/sandbox interception belongs on `tools/execute`. + * + * @module @deepseek-ai/dsh-fs-policy + */ + +import type { Context } from 'cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsPolicyExec } from './types.ts' + +export type { FsPolicyExec } from './types.ts' + +/** + * Per-context observed-file state and the three `fs/*` decisions over it. One + * instance is created per `apply()` so disposal can drop all state for HMR. + */ +class ObservedStateGate { + /** + * Observed-file state, keyed first by the owner object (weakly held, so a + * collected session frees its state), then by {@link FsTarget.targetKey}. An + * entry's PRESENCE is the prior-observation record. + */ + private observed = new WeakMap<object, Map<string, FsVersion>>() + + /** + * Derive the observed-state owner from the opaque event actor — normally the + * active agent session. `undefined` when no owner can be derived (e.g. a + * direct tool call with no agent); such calls read freely but cannot satisfy + * the write/edit prior-observation policy. + */ + private owner(actor: object | undefined): object | undefined { + return (actor as FsPolicyExec | undefined)?.agent?.session + } + + private get(owner: object, targetKey: string): FsVersion | undefined { + return this.observed.get(owner)?.get(targetKey) + } + + private set(owner: object, targetKey: string, version: FsVersion): void { + let byTarget = this.observed.get(owner) + if (!byTarget) { + byTarget = new Map() + this.observed.set(owner, byTarget) + } + byTarget.set(targetKey, version) + } + + /** Drop all recorded state (HMR safety / disposal). */ + clear(): void { + this.observed = new WeakMap() + } + + /** + * Decide the write intent: no prior observation ⇒ `createIfAbsent` (only + * new files can be created blindly); a prior observation ⇒ `replaceIfVersion` + * at the observed version (existing files replaced only if unchanged). + */ + writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent { + const owner = this.owner(actor) + const prior = owner ? this.get(owner, target.targetKey) : undefined + return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } + } + + /** + * Decide the edit version guard: requires a prior observation by this owner + * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. + */ + editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } { + const owner = this.owner(actor) + const prior = owner ? this.get(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + return { version: prior } + } + + /** Record a successful read/write/edit: this owner observed this target at this version. */ + observe(target: FsTarget, version: FsVersion, actor: object | undefined): void { + const owner = this.owner(actor) + if (owner) this.set(owner, target.targetKey, version) + } +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-policy' + +/** + * Register the three `fs/*` listeners. No `inject` — this plugin reads no + * services; it operates only on its own `WeakMap`. The waterfalls are unbound + * (the tool dispatches them with no `this`), so the listeners take the raw + * `(target, actor, next)` arguments. + */ +export function apply(ctx: Context): void { + const gate = new ObservedStateGate() + + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded plugin starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the + // release observable and immediate for tests. + gate.clear() + }, 'fs-policy observed-state teardown') + + // fs/write-intent: occupy the single decision slot — do NOT call next(). + // Deferred through Promise.resolve().then so the declared Promise return type + // holds (a throw rejects, never escapes synchronously through the waterfall). + ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor))) + + // fs/edit-intent: occupy the single decision slot — do NOT call next(). + // Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise + // the edit tool's `await ctx.waterfall(...)` surfaces as its isError result. + ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) + + // fs/observed: synchronous, side-effect-only WeakMap write. The tool emits + // this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw — + // a throw would surface as the tool's isError result for a mutation that + // already succeeded. A WeakMap.set honors that contract. + ctx.on('fs/observed', (target, version, actor) => { + gate.observe(target, version, actor) + }) +} diff --git a/packages/fs/fs-policy/src/types.ts b/packages/fs/fs-policy/src/types.ts new file mode 100644 index 0000000000..9ee742a7f7 --- /dev/null +++ b/packages/fs/fs-policy/src/types.ts @@ -0,0 +1,29 @@ +/** + * Vocabulary for the fs-policy plugin: the minimal execution-context + * shape used to derive an observed-state owner by narrowing the opaque `object` + * actor the `fs/*` events carry. + * + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state + * owner structure on top of it. + * + * @module @deepseek-ai/dsh-fs-policy/types + */ + +/** + * Minimal structural view of a tool execution the policy plugin needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to this + * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * + * The owner is `agent.session` when present. It is treated as an opaque object + * identity (a `WeakMap` key); this package never reads any of its fields. + */ +export interface FsPolicyExec { + /** The agent on whose behalf the call runs, when there is one. */ + agent?: { + /** The session that owns observed-file state, used as an opaque key. */ + session?: object + } +} diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts new file mode 100644 index 0000000000..c41cf45701 --- /dev/null +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -0,0 +1,215 @@ +/** + * Tests for the fs-policy PLUGIN: it registers no service, only the + * three `fs/*` listeners. We dispatch those events directly (the unbound + * waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the + * decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread + * edit, observed-state-as-prior-observation (read/write/edit all record), + * multi-owner isolation, single-slot first-wins, and disposal/HMR release. + * + * No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only + * decides intents and records versions on its own WeakMap. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' + +function target(path: string): FsTarget { + return { targetKey: FsTargetKey(path), displayPath: path } +} +const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) + +/** Dispatch the write-intent waterfall with the bare default thunk. */ +function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> { + return ctx.waterfall('fs/write-intent', t, actor, () => undefined) +} +/** Dispatch the edit-intent waterfall with the bare default thunk. */ +function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { + return ctx.waterfall('fs/edit-intent', t, actor, () => undefined) +} + +async function setup() { + const ctx = new Context() + const fiber = await ctx.plugin(FsPolicy) + return { ctx, fiber } +} + +describe('registration / disposal', () => { + it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => { + const { ctx } = await setup() + expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined() + }) + + it('mounts with no inject (reads no services)', async () => { + // It mounts immediately even with nothing else in the context. + const ctx = new Context() + await ctx.plugin(FsPolicy) + // The listener is live: an unobserved write decides createIfAbsent. + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + }) +}) + +describe('write-intent decision', () => { + it('an unobserved target decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) + }) + + it('a no-owner actor decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) + }) + + it('an actor with an agent but no session has no owner (createIfAbsent)', async () => { + // The middle optional-chain rung: agent present, session undefined ⇒ owner + // undefined ⇒ unobservable, so a write can only be a blind create. + const { ctx } = await setup() + expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).toEqual({ kind: 'createIfAbsent' }) + }) + + it('an observed target decides replaceIfVersion at the observed version', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) + }) +}) + +describe('edit-intent decision', () => { + it('rejects an unread edit with FS_NOT_OBSERVED', async () => { + const { ctx } = await setup() + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects an edit with no owner (cannot prove prior observation)', async () => { + const { ctx } = await setup() + await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects an edit whose actor has an agent but no session (no owner)', async () => { + const { ctx } = await setup() + await expect(editIntent(ctx, target('a.txt'), { agent: {} })).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('returns the observed version as the CAS basis after an observation', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) + }) +}) + +describe('observed-state is the prior-observation record', () => { + it('a read observation authorizes an in-place write at that version', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + }) + + it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + // A create records v1; the follow-up edit guards against v1 with no read. + ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) + // The edit records v2; a second edit guards against v2. + ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) + }) + + it('a no-owner observation records nothing', async () => { + const { ctx } = await setup() + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined) + // Still unobserved for any owner. + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A observing does not grant owner B edit authority', async () => { + const { ctx } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) + await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) + }) + + it('each owner records its own observed version independently', async () => { + const { ctx } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 + // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. + expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + }) +}) + +describe('single-slot, first-wins', () => { + it('fully decides the slot without calling next() (the bare default is unreached)', async () => { + const { ctx } = await setup() + let defaultRan = false + const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => { + defaultRan = true + return undefined + }) + expect(intent).toEqual({ kind: 'createIfAbsent' }) + expect(defaultRan).toBe(false) + }) + + it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => { + const { ctx } = await setup() + let secondRan = false + // Registered after fs-policy, so it dispatches second; fs-policy does + // not call next(), so this never runs. (A decider registered BEFORE — or with + // prepend — would instead win: first-wins is by convention, not enforced.) + ctx.on('fs/edit-intent', () => { + secondRan = true + return Promise.resolve(undefined) + }) + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + await editIntent(ctx, target('a.txt'), exec) + expect(secondRan).toBe(false) + }) + + it('a SECOND write-intent decider registered AFTER fs-policy is not reached', async () => { + const { ctx } = await setup() + let secondRan = false + ctx.on('fs/write-intent', () => { + secondRan = true + return Promise.resolve(undefined) + }) + await writeIntent(ctx, target('a.txt'), ownerExec({})) + expect(secondRan).toBe(false) + }) +}) + +describe('disposal releases recorded state (HMR safety)', () => { + it('a fresh plugin after disposal starts with no inherited state', async () => { + const ctx = new Context() + const exec = ownerExec({}) + const fiber = await ctx.plugin(FsPolicy) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) + await fiber.dispose() + + await ctx.plugin(FsPolicy) + // Same owner object, but state was released on disposal. + await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('no listeners remain after disposal (the gate no longer decides)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FsPolicy) + await fiber.dispose() + // With no listener, the waterfall falls through to the bare default. + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() + }) +}) diff --git a/packages/fs/fs-policy/tsconfig.json b/packages/fs/fs-policy/tsconfig.json new file mode 100644 index 0000000000..fcc1307a36 --- /dev/null +++ b/packages/fs/fs-policy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md new file mode 100644 index 0000000000..ac2866802b --- /dev/null +++ b/packages/fs/fs/README.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-fs + +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. + +This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): + +| Layer | Package | Role | +|---|---|---| +| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | +| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | +| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | + +A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change. + +## Service API (`ctx.fs`) + +A backend subclasses `FileSystem` and implements seven primitives. + +| Member | Semantics | +|---|---| +| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | +| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | +| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | +| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | + +The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity. + +## The `fs/*` policy events + +This package declares three events (see the generated [events catalog](../../../docs/cordis-catalog/events.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. + +## A provider seam, not the policy layer + +`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. + +`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit. + +## Vocabulary + +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json new file mode 100644 index 0000000000..813cb04e16 --- /dev/null +++ b/packages/fs/fs/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-fs", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-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/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts new file mode 100644 index 0000000000..1e0ab03b85 --- /dev/null +++ b/packages/fs/fs/src/index.ts @@ -0,0 +1,260 @@ +/** + * The filesystem provider seam (`ctx.fs`): an abstract service defining the + * text-storage primitives a backend provides — resolve a path into a stable + * target, stat its metadata, read/stream its text, write it atomically with an + * explicit intent, and apply a guarded literal edit — without saying HOW. + * Implementations subclass {@link FileSystem} and register themselves as the + * `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first. + * Future implementations swap in sandboxed, remote, virtual, or project-scoped + * backends without touching the model-facing tool schemas + * (`@deepseek-ai/dsh-tool-fs`). + * + * The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the + * capability-seam RFC for why a swappable capability is three (here four) + * packages. + * + * ## This is a provider seam, not the policy layer + * + * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns + * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the + * literal-edit critical section — but NOT line windows, numbered lines, + * rendered footers, or observed-state. Read windowing lives in the model-facing + * tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit + * are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*` + * event gate. So a sandboxed/remote backend inherits no model-facing observation + * policy it has no business carrying. + * + * `editText` stays on this seam (not composed in the policy layer from a read + * plus a write) because version guard + literal match + atomic rewrite must + * stay inside one mutation critical section for correct error attribution and + * one-wins/one-stale concurrency, and a remote backend may implement it as a + * native compare-and-edit. + * + * ## The version guard is OPTIONAL — additive policy, not subtractive + * + * `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read` + * reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally + * replaces literal text in the current content. Both mutations take their + * version guard as an OPTIONAL argument — omit it for the unconstrained + * bare-provider behavior, supply it to guard against a concurrent change. The + * mutation runs inside the backend's per-target lock either way, so an + * unconditional write/edit is still atomic; "unconditional" drops the *version* + * precondition, not the atomicity. Observed-state, read-before-edit, and + * version-guarded write/edit are NOT provider behavior — they are policy a + * plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard. + * + * ## The fs policy events live here, not in the policy plugin + * + * This package owns the `fs/write-intent`, `fs/edit-intent`, and + * `fs/observed` event vocabulary (see {@link Events}). The emitter is + * `@deepseek-ai/dsh-tool-fs` and the default listener is + * `@deepseek-ai/dsh-fs-policy`; the events live in the one package both + * already depend on, so the emitter shares a vocabulary with the policy listener + * without depending on the policy plugin. The events carry only `dsh-fs` + * vocabulary plus an opaque `object` actor — no model-facing concepts (line + * windows, numbered lines) and no agent/session owner structure leak down. + * + * @module @deepseek-ai/dsh-fs + */ + +import { Context, Service } from 'cordis' +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsVersion, + FsWriteIntent, + FsWriteOutcome, +} from './types.ts' + +export { + FsError, + FsTargetKey, + FsVersion, +} from './types.ts' +export type { + FsEditOutcome, + FsEditRequest, + FsDirEntry, + FsErrorCode, + FsInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from './types.ts' + +declare module 'cordis' { + interface Context { + fs: FileSystem + } + + interface Events { + /** + * Single-slot decision: produce the write intent for the next + * {@link FileSystem.writeText}. The tool dispatches this as an unbound + * waterfall (no `this`) and supplies a default thunk returning `undefined` + * (unconditional create-or-overwrite — the bare provider). The + * `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` + * (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` + * (observed) and does NOT call `next()` — one decision, not a composable + * chain. The slot is first-wins: the first non-`next()` decider (registration + * order, or `prepend`) occupies it; a second decider is a misconfiguration, + * not layering. `actor` is the opaque tool-execution context, never read here. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined> + /** + * Single-slot decision: produce the optional version guard for the next + * {@link FileSystem.editText}. The tool dispatches this as an unbound + * waterfall and supplies a default thunk returning `undefined` (unconditional + * edit of the current content — the bare provider; no `stat`). The + * `@deepseek-ai/dsh-fs-policy` policy listener returns + * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset + * or has not observed the target. Does NOT call `next()`: one decision, + * first-wins (see {@link Events.'fs/write-intent'}). + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + /** + * Record that an actor observed a target at a version, after a successful + * read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a + * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s + * is a `WeakMap.set`): the tool does not guard the emit, so a listener that + * throws surfaces as the tool's `isError` result, and cordis `emit` does not + * await listener promises — async or fallible audit/telemetry does not + * belong here. No listener ⇒ nothing recorded. `actor` is the opaque + * tool-execution context. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. + * @mode emit + */ + 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void + } +} + +/** + * Abstract filesystem provider service. Subclass, implement the seven storage + * primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one + * implementation per context; loading a second throws, cordis' standard + * duplicate-service behavior). + * + * Semantics every backend must honor: + * - {@link resolve} returns a stable {@link FsTarget}; the same underlying file + * reached by different input paths must yield the same `targetKey` so stale + * guards and target lookup agree across paths (e.g. through symlinks). + * - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined` + * when the target is absent. + * - {@link readText}/{@link streamText} read the whole regular text file (the + * stream for large files); both own regular-file checks, UTF-8 decoding, + * binary/NUL rejection, and `FS_NOT_TEXT`. + * - {@link listDir} returns direct children of a directory in stable name order + * with resolved child targets and cheap metadata only. It never reads file + * contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw + * `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and + * other backend I/O failures throw `FS_IO_ERROR`. + * - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL: + * omit it for an unconditional create-or-overwrite (the bare-provider default), + * or supply a {@link FsWriteIntent} to guard the write. + * - {@link editText} verifies `expected.version` BEFORE literal matching (so a + * stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ + * `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement + * and writes atomically — all inside one mutation critical section. `expected` + * is OPTIONAL: omit it for an unconditional edit of the current content (a + * missing target still reports `FS_STALE_VERSION`). + */ +export abstract class FileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + /** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May + * perform I/O (a remote/sandboxed backend may need a round-trip to map a path + * to a stable identity), hence async even though the local backend only + * normalizes + realpaths. + * + * `opts.cwd` is the base directory a RELATIVE `path` resolves against; an + * absolute `path` ignores it. Omitted ⇒ the backend's own default base (the + * local backend uses its configured `cwd`). The CALLER supplies this — the + * seam does not read a session or agent — so a tool can resolve against the + * caller's per-session workspace (`exec.agent.session.header.cwd`) without the + * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` + * defaults a bash `workdir` to the session cwd. + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @returns the stable target; the same file yields the same `targetKey`. + */ + abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> + + /** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ + abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> + + /** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ + abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string> + + /** + * Stream the whole regular text file as decoded text chunks (same text + * semantics as {@link readText}, for large files). The backend owns + * cross-chunk UTF-8 decoding and binary rejection so the policy layer never + * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. + */ + abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> + + /** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. + */ + abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> + + /** + * Create or fully replace a UTF-8 text file atomically. `expected` is the + * create-vs-replace decision and stale guard when supplied; OMITTING it is an + * unconditional create-or-overwrite (the bare provider — no version guard, no + * read-first requirement). Atomic either way. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. + */ + abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> + + /** + * Apply a literal edit to an existing UTF-8 text file. When `expected` is + * supplied, verifies `expected.version` as the stale guard BEFORE literal + * matching; OMITTING it edits the current content unconditionally (no version + * guard). Either way applies the replacement and writes atomically — one + * mutation critical section — and a missing target reports `FS_STALE_VERSION`. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. + */ + abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> +} + +export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts new file mode 100644 index 0000000000..9351a373db --- /dev/null +++ b/packages/fs/fs/src/types.ts @@ -0,0 +1,186 @@ +/** + * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque + * target/version identities, the metadata `stat` returns, the write-intent + * and outcome shapes, the literal-edit request/outcome, and the typed error + * taxonomy. + * + * These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and + * future sandboxed/remote backends) and by the policy layer + * (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage* + * vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand + * back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey` + * and `version` are opaque branded tokens, and `displayPath` is the only field a + * consumer may show. + * + * Model-facing concepts (line windows, numbered lines, observed-state) do NOT + * live here; they belong to the consumer tool and the policy plugin + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`). + * + * @module @deepseek-ai/dsh-fs/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Opaque key for stale guards and target lookup. The local backend uses a + * realpath-like string; a remote backend might use a workspace URI or file id. + * Consumers MUST NOT parse it or assume it is a local absolute path. + */ +export type FsTargetKey = Branded<'FsTargetKey'> + +/** Brand a string as an {@link FsTargetKey}. */ +export function FsTargetKey(key: string): FsTargetKey { + return key as FsTargetKey +} + +/** + * Opaque file-version token — the freshness token a write/edit guards against. + * The local backend derives it from mtime+size; a remote backend might use a + * revision id. The policy layer records it for stale checks; consumers may + * display related metadata but MUST NOT interpret this token. + */ +export type FsVersion = Branded<'FsVersion'> + +/** Brand a string as an {@link FsVersion}. */ +export function FsVersion(v: string): FsVersion { + return v as FsVersion +} + +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ +export interface FsTarget { + /** Opaque key for stale guards and target lookup. */ + targetKey: FsTargetKey + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ + displayPath: string +} + +/** + * Metadata about a target — what {@link FileSystem.stat} returns. Lets the + * policy layer reject directories/special files before reading and choose + * `readText` vs `streamText` from `size` without probing by failure. `version` + * is the freshness token. `undefined` from `stat` means the target is absent. + */ +export interface FsInfo { + /** Opaque freshness token of the target right now. */ + version: FsVersion + /** Whether the target is a regular file, a directory, or something else. */ + type: 'file' | 'directory' | 'other' + /** Byte size of a regular file, when the backend can report it. */ + size?: number +} + +/** + * One direct child returned by {@link FileSystem.listDir}. Listing returns + * metadata and resolved targets only; it must not read file contents. + */ +export interface FsDirEntry { + /** Basename of the child inside the listed directory. */ + name: string + /** Whether the child is a regular file, a directory, or something else. */ + type: 'file' | 'directory' | 'other' + /** Resolved child target for follow-up operations. */ + target: FsTarget + /** Opaque freshness token when the backend can report metadata cheaply. */ + version?: FsVersion + /** Byte size of a regular file, when the backend can report it. */ + size?: number +} + +/** + * The explicit intent of a guarded {@link FileSystem.writeText} call. + * `createIfAbsent` creates a missing target and rejects an existing one with + * `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior + * read). `replaceIfVersion` replaces only when the target exists at the observed + * version; a missing target or a version mismatch throws `FS_STALE_VERSION`. + * + * `writeText` takes this OPTIONALLY: omitting `expected` is the third, + * unconstrained state — an unconditional create-or-overwrite (the bare + * provider). The union itself carries only the two GUARDED intents; "no guard" + * is expressed by omission, so the write and edit mutations share one symmetric + * shape (`expected?`: omit = unconditional, present = guarded). + */ +export type FsWriteIntent = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } + +/** Outcome of a full-file write. */ +export interface FsWriteOutcome { + /** Whether the write created a new file or replaced an existing one. */ + operation: 'create' | 'update' + /** Opaque version of the file after the write. */ + version: FsVersion + /** + * The file's content BEFORE the write, or `null` when the file did not exist + * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text + * (the diff basis), never a diff — a consumer computes the result-time + * contextual diff from `before`/`after` when `before` is present, else falls + * back to a whole-file diff. + */ + before: string | null + /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ + after: string +} + +/** A literal-replacement edit request. */ +export interface FsEditRequest { + /** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */ + oldString: string + /** Literal replacement text. An empty string deletes the matched text. */ + newString: string + /** Replace every match instead of requiring exactly one. */ + replaceAll: boolean +} + +/** Outcome of a literal edit. */ +export interface FsEditOutcome { + /** Opaque version of the file after the edit. */ + version: FsVersion + /** + * The file's content BEFORE the edit. Raw storage text (LF-normalized by the + * backend), never a diff — a consumer computes the result-time contextual diff + * (the applied hunk with context) from `before`/`after`. + */ + before: string + /** The file's content AFTER the edit. */ + after: string +} + +/** + * Stable, machine-routable codes for filesystem failures. Carried on + * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` + * results so retry/permission/UI layers can branch without parsing messages. + */ +export type FsErrorCode = + | 'FS_NOT_FOUND' + | 'FS_NOT_DIRECTORY' + | 'FS_NOT_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_PERMISSION_DENIED' + | 'FS_IO_ERROR' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' + +/** + * Typed filesystem error. Extends {@link HarnessError} so it carries a stable + * {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so + * backends and the policy layer raise the same codes instead of each inventing + * message strings. + */ +export class FsError extends HarnessError { + override readonly code: FsErrorCode + + constructor(message: string, code: FsErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.code = code + } +} diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts new file mode 100644 index 0000000000..86ba782c96 --- /dev/null +++ b/packages/fs/fs/tests/service.spec.ts @@ -0,0 +1,146 @@ +/** + * Tests for the filesystem provider seam itself: registration, duplicate-service + * behavior, disposal, and the branded id factories. The provider primitives and + * policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the + * abstract service contract, so a minimal fake backend exercises it. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' + +/** A minimal in-memory fake implementing the seven provider primitives. */ +class FakeFileSystem extends FileSystem { + files = new Map<string, string>() + + override async resolve(path: string): Promise<FsTarget> { + return { targetKey: FsTargetKey(path), displayPath: path } + } + override async stat(target: FsTarget): Promise<FsInfo | undefined> { + const content = this.files.get(target.targetKey) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } + override async readText(target: FsTarget): Promise<string> { + const content = this.files.get(target.targetKey) + if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') + return content + } + override async streamText(target: FsTarget): Promise<AsyncIterable<string>> { + const content = await this.readText(target) + return (async function* () { yield content })() + } + override async listDir(target: FsTarget): Promise<FsDirEntry[]> { + if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY') + return [ + { + name: 'alpha.md', + type: 'file', + target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' }, + size: 2, + version: FsVersion('v1'), + }, + ] + } + override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> { + const before = this.files.get(target.targetKey) ?? null + this.files.set(target.targetKey, content) + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } + } + override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> { + const content = this.files.get(target.targetKey) ?? '' + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { version: FsVersion('v3'), before: content, after } + } +} + +describe('FileSystem provider seam', () => { + it('registers as ctx.fs and serves the primitives', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.txt', 'hi') + const target = await fs.resolve('a.txt') + expect((await fs.stat(target))?.type).toBe('file') + expect(await fs.readText(target)).toBe('hi') + }) + + it('throws when a second implementation is loaded (duplicate service)', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow() + }) + + it('removes the service when the providing fiber is disposed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FakeFileSystem) + expect(ctx.fs).toBeDefined() + await fiber.dispose() + expect(ctx.fs).toBeUndefined() + }) + + it('streamText yields the same text readText returns', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.txt', 'one\ntwo') + const target = await fs.resolve('a.txt') + let streamed = '' + for await (const chunk of await fs.streamText(target)) streamed += chunk + expect(streamed).toBe(await fs.readText(target)) + }) + + it('listDir returns child entry targets without reading file content', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + const entries = await fs.listDir(await fs.resolve('skills')) + expect(entries).toEqual([{ + name: 'alpha.md', + type: 'file', + target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' }, + size: 2, + version: 'v1', + }]) + }) + + it('stat returns undefined for an absent target', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined() + }) +}) + +describe('branded id factories', () => { + it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => { + expect(FsTargetKey('k')).toBe('k') + expect(FsVersion('v')).toBe('v') + }) +}) + +describe('FsError', () => { + it('carries a stable code and HarnessError name', () => { + const error = new FsError('nope', 'FS_NOT_FOUND') + expect(error.code).toBe('FS_NOT_FOUND') + expect(error.name).toBe('FsError') + expect(error).toBeInstanceOf(Error) + }) + + it('chains an underlying cause through ErrorOptions', () => { + const root = new Error('EACCES') + const error = new FsError('cannot read', 'FS_ABORTED', { cause: root }) + expect(error.cause).toBe(root) + expect(error.code).toBe('FS_ABORTED') + }) +}) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json new file mode 100644 index 0000000000..a352aea65a --- /dev/null +++ b/packages/fs/fs/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../llm/llm" } + ] +} diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md new file mode 100644 index 0000000000..fedd1ff7a2 --- /dev/null +++ b/packages/fs/tool-fs/README.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-tool-fs + +The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. + +```ts ignore-check +// Default deployment: a ctx.fs provider, the policy plugin, then the tools. +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local +await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) +await ctx.plugin(ToolFs) // this package — registers read/write/edit +``` + +`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. + +## Config + +All keys are optional; the defaults are the shipped read caps. + +| Key | Default | Meaning | +|---|---|---| +| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | +| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | +| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | +| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. | + +## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) + +| Tool | Arguments | Behavior | +|---|---|---| +| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | +| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | + +Field names are snake_case to match Claude Code and existing harness tool schemas. + +## The tool is the executor; policy is an event gate + +The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: + +- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) +- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) +- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) + +The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +## `fs/observed` is fire-and-forget + +`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. + +The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json new file mode 100644 index 0000000000..7e7b78aa38 --- /dev/null +++ b/packages/fs/tool-fs/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tool-fs", + "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "diff": "^9.0.0", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^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-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts new file mode 100644 index 0000000000..a45082489a --- /dev/null +++ b/packages/fs/tool-fs/src/diff.ts @@ -0,0 +1,92 @@ +/** + * Result-time contextual-diff computation for the `write`/`edit` tools. Turns a + * before/after pair of file texts into one {@link FileDiff} per applied hunk — + * each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with + * ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp + * renders an editor inline diff. + * + * This is display-only presentation vocabulary (a UI concern), so it lives in + * the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns + * only the raw before/after text (storage facts) and the tool computes the diff. + * + * @module @deepseek-ai/dsh-tool-fs/src/diff + */ + +import { structuredPatch } from 'diff' +import type { FileDiff } from '@deepseek-ai/dsh-tools' + +/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */ +export const DIFF_CONTEXT = 3 + +/** + * The `write`/`edit` tools' private `tool/result` `meta` payload: the applied + * contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and + * persisted with the session log — it must be JSON-serializable (the session + * validates this at `append`), so `presentResult` reproduces the diff card on + * replay. The producing tool owns this shape; the bridge only sees the opaque + * `meta` and the tool narrows it back via {@link diffsFromMeta}. + */ +export type FsDiffMeta = { diffs: FileDiff[] } + +/** + * Compute one {@link FileDiff} per hunk between `before` and `after`, each + * carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an + * empty array when the texts are identical (no hunks). For a scattered + * `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s + * come back — matching the editor rendering one diff block per site. + * + * Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`; + * `newText` is its `+` (added) and context lines. A hunk with no old lines + * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring + * the call-time card's new-file convention. The unified-diff "\ No newline at end + * of file" markers are dropped — they annotate the patch, not file content. + */ +export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] { + const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT }) + const diffs: FileDiff[] = [] + for (const hunk of patch.hunks) { + const oldLines: string[] = [] + const newLines: string[] = [] + for (const line of hunk.lines) { + // The unified-diff marker for a missing trailing newline annotates the + // patch, not the content — skip it so it never leaks into a diff block. + if (line.startsWith('\\')) continue + const text = line.slice(1) + if (line.startsWith('-')) { + oldLines.push(text) + } else if (line.startsWith('+')) { + newLines.push(text) + } else { + // A context (unchanged) line appears on both sides. + oldLines.push(text) + newLines.push(text) + } + } + diffs.push({ path, oldText: oldLines.length > 0 ? oldLines.join('\n') : null, newText: newLines.join('\n') }) + } + return diffs +} + +/** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */ +function isFileDiff(value: unknown): value is FileDiff { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, oldText, newText } = value as Record<string, unknown> + return typeof path === 'string' + && (oldText === null || typeof oldText === 'string') + && typeof newText === 'string' +} + +/** + * Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff} + * hunks, or `undefined` when it is absent/malformed. `presentResult` runs on + * arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so + * it validates defensively rather than trusting the payload — a bad `meta` yields + * `undefined`, and the caller decides the fallback (edit → the generic result + * rendering; write → an args-derived whole-file diff), never a thrown presenter. + */ +export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const diffs = (meta as Record<string, unknown>).diffs + if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined + return diffs +} diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts new file mode 100644 index 0000000000..5ede545b03 --- /dev/null +++ b/packages/fs/tool-fs/src/edit.ts @@ -0,0 +1,120 @@ +/** + * The model-facing `edit` tool: update an existing UTF-8 text file by replacing + * literal text, requiring a unique match by default. The tool is the executor: + * it dispatches the `fs/edit-intent` waterfall to obtain the optional + * version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The + * default thunk returns `undefined` (unconditional edit of the current content + * — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`) + * occupies the single decision slot, returning `{ version: vObserved }` or + * throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times + * either way; a missing target is reported by the provider as `FS_STALE_VERSION`. + * + * @module @deepseek-ai/dsh-tool-fs/src/edit + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { sessionCwd } from './session-cwd.ts' + +/** Validated `edit` arguments after defaulting. */ +interface EditInput { + filePath: string + oldString: string + newString: string + replaceAll: boolean +} + +/** Validate value constraints the schema DSL can't express. */ +export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string') + if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ') + return { + filePath: args.file_path, + oldString: args.old_string, + newString: args.new_string, + replaceAll: args.replace_all ?? false, + } +} + +/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */ +export function formatEditOutput(displayPath: string, replaceAll: boolean): string { + return replaceAll + ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` + : `The file ${displayPath} has been updated successfully.` +} + +/** Register the `edit` tool and its system-prompt guidance. */ +export function applyEditTool(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:edit', + order: 102, + text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.', + }) + + ctx.tools.register(defineTool({ + name: 'edit', + description: 'Edit an existing UTF-8 text file by replacing literal text.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' }, + old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' }, + new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' }, + replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' }, + }, + async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + const input = parseEditArgs(args) + const cwd = sessionCwd(exec) + const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + // Single-slot decision: the policy plugin returns { version: vObserved } or + // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). + // No stat — the bare default never manufactures a version basis. + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + const outcome = await ctx.fs.editText( + target, + { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, + intent, + exec.signal, + ) + // Record the observed version (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, outcome.version, exec) + // The result-time applied-hunk diff (before→after with context lines). An + // edit always changes content (parseEditArgs requires old_string to differ + // and editText matches at least once), so there is always at least one hunk. + // The bridge renders these as an inline diff that supersedes the call-time + // snippet; the display path is the model-facing `file_path` (the bridge + // relativizes it). + const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) + return { + content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], + meta: { diffs }, + } + }, + // Pure display: a diff card of the literal replacement (old_string → + // new_string), derived from the call args. `oldText: old_string || null` + // matches claude-agent-acp's Edit arm; new_string is a required arg here, so + // it maps straight to newText. A follow-along location points at the file. + presentCall(args): DiffCallView { + return { + card: 'diff', + title: `Edit ${args.file_path}`, + diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }], + locations: [{ path: args.file_path }], + } + }, + // Result-time display: the applied contextual-diff hunks carried on `meta`. + // On success with diffs, a `diff` result card supersedes the call-time + // snippet; on error (nothing applied) or malformed meta, fall through to the + // generic "updated successfully" rendering. + presentResult(args, result: ToolResult): DiffResultView | undefined { + if (result.isError) return undefined + const diffs = diffsFromMeta(result.meta) + if (diffs === undefined) return undefined + return { card: 'diff', title: `Edit ${args.file_path}`, diffs } + }, + })) +} diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts new file mode 100644 index 0000000000..f5d0d9ef91 --- /dev/null +++ b/packages/fs/tool-fs/src/index.ts @@ -0,0 +1,92 @@ +/** + * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the + * `ctx.fs` provider seam. This single plugin registers all three tools. + * + * ## The tool is the executor; policy is an event gate + * + * The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing + * concerns only — tool names, JSON schemas, argument validation, prompt + * sections, read windowing, result formatting. It does NOT inject a policy + * service. Instead, on each write/edit it dispatches a single-slot waterfall + * (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and + * after every read/write/edit it emits `fs/observed` with a plain (unguarded) + * `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the + * decision slot and listens for `fs/observed` to add observed-state + + * read-before-edit + version-guarded write/edit; a deployment that loads these + * tools is expected to also load it. With no policy plugin the waterfalls fall + * through to their `undefined` default (the unconstrained bare provider) and + * `fs/observed` is unheard — the tool still functions. This package never + * imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local` + * implementation. + * + * @module @deepseek-ai/dsh-tool-fs + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' +import { applyWriteTool } from './write.ts' +import { applyEditTool } from './edit.ts' +import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' + +export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' +export type { ReadToolCaps } from './read.ts' +export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' +export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' +export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts' +export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts' +export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts' +export type { FsDiffMeta } from './diff.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-fs' + +/** Services required by the filesystem tool suite. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Default and maximum number of lines returned by one `read` call. */ + readLimit?: number + /** Maximum characters returned for a single line before truncation. */ + readMaxLineLength?: number + /** Maximum bytes returned for the selected lines of one `read` call. */ + readMaxBytes?: number + /** Files at or above this size stream instead of loading whole into memory. */ + readStreamMinSize?: number +} + +export const Config: z<Config> = z.object({ + readLimit: z.number().default(READ_LIMIT), + readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH), + readMaxBytes: z.number().default(READ_MAX_BYTES), + readStreamMinSize: z.number().default(STREAM_MIN_SIZE), +}) + +/** The shape after schemastery applied the defaults. */ +type ResolvedConfig = Required<Config> + +/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-fs: ${name} must be a positive integer`) + } +} + +/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveInteger('readLimit', resolved.readLimit) + assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength) + assertPositiveInteger('readMaxBytes', resolved.readMaxBytes) + assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize) + applyReadTool(ctx, { + limit: resolved.readLimit, + maxLineLength: resolved.readMaxLineLength, + maxBytes: resolved.readMaxBytes, + streamMinSize: resolved.readStreamMinSize, + }) + applyWriteTool(ctx) + applyEditTool(ctx) +} diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts new file mode 100644 index 0000000000..c54279baa9 --- /dev/null +++ b/packages/fs/tool-fs/src/read-render.ts @@ -0,0 +1,178 @@ +/** + * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's + * decoded text into a bounded, line-numbered window (offset/limit, byte cap, + * per-line truncation) and format it as the model-facing text block. This is + * the `read` tool's RENDERING detail — not a storage primitive, not freshness + * policy — so it lives apart from the tool's I/O and event wiring as a pure, + * independently-testable module (no cordis, no filesystem). + * + * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text + * (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text + * for newlines and builds the requested window. A capped line buffer means a + * newline-free giant line can never balloon memory even when streamed. + * {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the + * `<path>/<content>` envelope the model sees. + * + * @module @deepseek-ai/dsh-tool-fs/read-render + */ + +import { FsError } from '@deepseek-ai/dsh-fs' + +/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */ +export const READ_MAX_LINE_LENGTH = 2000 + +/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */ +export const READ_MAX_BYTES = 50 * 1024 + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface ReadWindow { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number + /** Maximum characters returned for a single line; overflow is truncated with a suffix. */ + maxLineLength: number + /** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */ + maxBytes: number +} + +/** One line returned from a text file. */ +export interface FileTextLine { + /** 1-based line number in the file. */ + number: number + /** Line text without its trailing newline. */ + text: string +} + +/** The windowed result {@link buildWindow} produces from a file's decoded text. */ +export interface WindowResult { + /** Returned lines, already numbered. */ + lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes: boolean +} + +/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Returned lines, already numbered. */ + lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes?: true +} + +interface WindowAccumulator { + lines: FileTextLine[] + totalLines: number + outputBytes: number + truncatedByBytes: boolean + done: boolean +} + +function newAccumulator(): WindowAccumulator { + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } +} + +function truncateLine(line: string, maxLineLength: number): string { + return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line +} + +function lineByteSize(line: string, currentLineCount: number): number { + return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) +} + +function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void { + acc.totalLines += 1 + if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + + const text = truncateLine(rawLine, request.maxLineLength) + const bytes = lineByteSize(text, acc.lines.length) + if (acc.outputBytes + bytes > request.maxBytes) { + acc.truncatedByBytes = true + acc.done = true + return + } + acc.outputBytes += bytes + acc.lines.push({ number: acc.totalLines, text }) +} + +function stripCarriageReturn(line: string): string { + return line.endsWith('\r') ? line.slice(0, -1) : line +} + +function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult { + if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) { + throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND') + } + return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes } +} + +/** + * Build a bounded, line-numbered window from a file's decoded text chunks. + * Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an + * `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code + * path serves both. Scans for newlines with a capped line buffer (a newline-free + * giant line is truncated, never buffered past `request.maxLineLength`), + * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. + */ +export async function buildWindow( + chunks: AsyncIterable<string> | Iterable<string>, + request: ReadWindow, + displayPath: string, +): Promise<WindowResult> { + const acc = newAccumulator() + // One char past the truncation point is enough to prove a line overflows. + const lineBufferCap = request.maxLineLength + 1 + let lineBuffer = '' + + function appendToLineBuffer(segment: string): void { + if (lineBuffer.length >= lineBufferCap) return + lineBuffer += segment + if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap) + } + + function flushLine(): void { + consumeLine(acc, stripCarriageReturn(lineBuffer), request) + lineBuffer = '' + } + + for await (const chunk of chunks) { + let startPos = 0 + let newlinePos: number + while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) { + appendToLineBuffer(chunk.slice(startPos, newlinePos)) + flushLine() + startPos = newlinePos + 1 + if (acc.done) return finish(acc, request, displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + } + if (lineBuffer.length > 0) flushLine() + return finish(acc, request, displayPath) +} + +/** Format a read outcome as one OpenCode-style line-numbered text block body. */ +export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string { + const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1) + let footer: string + if (outcome.truncatedByBytes) { + footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)` + } else if (endLine < outcome.totalLines) { + footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)` + } else { + footer = `(End of file - total ${outcome.totalLines} lines)` + } + const body = outcome.lines.length > 0 + ? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` + : footer + return `<path>${displayPath}</path> +<type>file</type> +<content> +${body} +</content>` +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts new file mode 100644 index 0000000000..a9cc4a9806 --- /dev/null +++ b/packages/fs/tool-fs/src/read.ts @@ -0,0 +1,141 @@ +/** + * The model-facing `read` tool: inspect a UTF-8 text file and return + * line-numbered content with pagination guidance. The tool is the executor — it + * stats and reads through `ctx.fs` directly, builds the line window + * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` + * so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With + * no policy plugin the emit is simply unheard. This module owns the + * model-facing schema, argument validation, and the read I/O; the rendering + * (windowing + formatting) lives in `read-render.ts` and the + * freshness/observation policy is not its concern. + * + * @module @deepseek-ai/dsh-tool-fs/src/read + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { FsError } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { buildWindow, formatReadOutput } from './read-render.ts' +import type { FileReadOutcome } from './read-render.ts' +import { sessionCwd } from './session-cwd.ts' + +/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ +export const READ_LIMIT = 2000 + +/** + * Default streaming threshold (the `readStreamMinSize` config): files at or + * above this size stream; smaller files read whole into memory. + */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 + +/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface ReadToolCaps { + /** Default and maximum number of lines returned by one call. */ + limit: number + /** Maximum characters returned for a single line. */ + maxLineLength: number + /** Maximum bytes returned for selected file lines. */ + maxBytes: number + /** Files at or above this size stream; smaller files read whole into memory. */ + streamMinSize: number +} + +/** Validated `read` arguments after defaulting. */ +interface ReadInput { + filePath: string + offset: number + limit: number +} + +function parsePositiveInteger(value: number, name: string): number { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`) + } + return value +} + +/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */ +export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') + const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit') + if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`) + return { filePath: args.file_path, offset, limit } +} + +/** Register the `read` tool and its system-prompt guidance. */ +export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { + ctx.systemPrompt.section({ + name: 'tool:read', + order: 100, + text: 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.', + }) + + ctx.tools.register(defineTool({ + name: 'read', + description: 'Read a UTF-8 text file and return line-numbered content.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' }, + offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, + limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, + }, + async execute(args, exec): Promise<ContentBlock[]> { + const input = parseReadArgs(args, caps.limit) + const cwd = sessionCwd(exec) + const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + + // One stat: type check + size routing + the version recorded as observed. + // A writer racing between this stat and the read can at worst make a LATER + // guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText + // re-checks the version in its lock). + const info = await ctx.fs.stat(target, exec.signal) + if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + // Stream when the file is large OR size is unknown, so a size-less backend + // never buffers an arbitrarily large file. + const chunks = info.size === undefined || info.size >= caps.streamMinSize + ? await ctx.fs.streamText(target, exec.signal) + : [await ctx.fs.readText(target, exec.signal)] + const window = await buildWindow( + chunks, + { offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes }, + target.displayPath, + ) + + const outcome: FileReadOutcome = { + offset: input.offset, + lines: window.lines, + totalLines: window.totalLines, + ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + // Record the observed version (a no-op when no policy plugin listens). The + // read already succeeded; an fs/observed listener is contractually a + // synchronous, side-effect-only recorder. + ctx.emit('fs/observed', target, info.version, exec) + return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + }, + // Pure display: a generic card titled by the file with the read window + // appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along + // location whose line is the read's offset (defaulting to 1). The window is + // derived from the RAW args (offset/limit as the model passed them), NOT the + // tool's defaulted 1/configured limit, so an unbounded read shows a bare + // title (and the presenter stays a pure function of args, config-free). + presentCall(args): GenericCallView { + const { offset, limit } = args + const window = limit !== undefined && limit > 0 + ? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` + : offset !== undefined ? ` (from line ${offset})` : '' + return { + card: 'generic', + title: `Read ${args.file_path}${window}`, + kind: 'read', + locations: [{ path: args.file_path, line: offset ?? 1 }], + } + }, + })) +} diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts new file mode 100644 index 0000000000..b7774fb201 --- /dev/null +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -0,0 +1,24 @@ +/** + * Derive the working directory a filesystem tool resolves relative paths + * against: the calling agent's per-session workspace + * (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit` + * act on ITS workspace, not the server's launch dir — mirroring how + * `dsh-tool-bash` defaults a bash `workdir` to the session cwd. + * + * The `agent` is optional-chained — a non-agent caller yields `undefined`, and + * the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies + * its own configured default (preserving the non-ACP / no-session behavior). + * `session`/`header` are non-optional on a real `Agent`, so only `agent` needs + * the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined` + * rather than reading `process.cwd()` here keeps the default in ONE place (the + * provider), per the "explicit > implicit at seams" convention. + * + * @module @deepseek-ai/dsh-tool-fs/session-cwd + */ + +import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +/** The session workspace cwd for this call, or `undefined` when none applies. */ +export function sessionCwd(exec: ToolExecution): string | undefined { + return exec.agent?.session.header.cwd +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts new file mode 100644 index 0000000000..1054e2ff2f --- /dev/null +++ b/packages/fs/tool-fs/src/write.ts @@ -0,0 +1,102 @@ +/** + * The model-facing `write` tool: create or fully replace a UTF-8 text file. The + * tool is the executor: it dispatches the `fs/write-intent` waterfall to + * obtain the optional version guard, calls `ctx.fs.writeText` directly, and + * emits `fs/observed`. The default thunk returns `undefined` (unconditional + * create-or-overwrite — the bare provider); a policy plugin + * (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and + * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO + * times either way. + * + * @module @deepseek-ai/dsh-tool-fs/src/write + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' +import { sessionCwd } from './session-cwd.ts' + +/** Validate value constraints the schema DSL can't express. */ +export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + return { filePath: args.file_path, content: args.content } +} + +/** Format a write outcome as one model-facing text block body. */ +export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string { + const verb = outcome.operation === 'create' ? 'Created' : 'Updated' + return `<path>${displayPath}</path> +<type>file</type> +<content> +${verb} file +</content>` +} + +/** Register the `write` tool and its system-prompt guidance. */ +export function applyWriteTool(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:write', + order: 101, + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.', + }) + + ctx.tools.register(defineTool({ + name: 'write', + description: 'Create or fully replace a UTF-8 text file.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' }, + content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, + }, + async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + const input = parseWriteArgs(args) + const cwd = sessionCwd(exec) + const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + // Single-slot decision: the policy plugin produces createIfAbsent/ + // replaceIfVersion; the bare default is undefined (unconditional). No stat. + const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) + const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) + // Record the observed version (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, outcome.version, exec) + // Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version + // exists). A create has no "before" — `outcome.before` is null — so it + // carries no `meta`; `presentResult` then renders a whole-file diff from the + // args, so the completed card is still a diff (never the result text). + const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] + return { + content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], + ...diffs.length > 0 ? { meta: { diffs } } : {}, + } + }, + // Pure display: a diff card (an editor renders write as a new-file / full- + // replace diff). `oldText: null` — a call-time presenter has no access to the + // file's prior content, so even an overwrite renders new-file style, matching + // claude-agent-acp. A follow-along location points at the written file. + presentCall(args): DiffCallView { + return { + card: 'diff', + title: `Write ${args.file_path}`, + diffs: [{ path: args.file_path, oldText: null, newText: args.content }], + locations: [{ path: args.file_path }], + } + }, + // Result-time display: a `diff` card so the completed `tool_call_update` + // re-installs the diff rather than the model-facing result text (an ACP + // `tool_call_update.content` REPLACES the call's content, so a text result + // would clobber the pending diff card). An OVERWRITE uses the applied + // contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so + // its whole-file new-file diff is derived from `args.content` (replay-safe, + // matching the call-time card). An error falls through to generic rendering + // so its message shows. + presentResult(args, result: ToolResult): DiffResultView | undefined { + if (result.isError) return undefined + const diffs = diffsFromMeta(result.meta) + ?? [{ path: args.file_path, oldText: null, newText: args.content }] + return { card: 'diff', title: `Write ${args.file_path}`, diffs } + }, + })) +} diff --git a/packages/fs/tool-fs/tests/diff.spec.ts b/packages/fs/tool-fs/tests/diff.spec.ts new file mode 100644 index 0000000000..12ab7209b6 --- /dev/null +++ b/packages/fs/tool-fs/tests/diff.spec.ts @@ -0,0 +1,113 @@ +/** + * Unit tests for the result-time contextual-diff computation (`src/diff.ts`): + * the pure before/after → {@link FileDiff}[] hunk builder and the defensive + * `meta` narrowing. These pin the exact hunk reconstruction (context lines, + * multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders. + */ + +import { describe, expect, it } from 'vitest' +import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs' +import type { JsonValue } from '@deepseek-ai/dsh-session' + +const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n' + +describe('computeHunkDiffs', () => { + it('a single-line change yields one hunk with ±context lines on both sides', () => { + const before = lines(8) + const after = before.replace('line4', 'CHANGED') + const diffs = computeHunkDiffs('f.txt', before, after) + expect(diffs).toEqual([{ + path: 'f.txt', + oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7', + newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7', + }]) + }) + + it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => { + const before = lines(20) + const after = before.replace('line3', 'A').replace('line16', 'B') + const diffs = computeHunkDiffs('f.txt', before, after) + expect(diffs).toHaveLength(2) + expect(diffs[0]?.path).toBe('f.txt') + expect(diffs[0]?.oldText).toContain('line3') + expect(diffs[0]?.newText).toContain('A') + expect(diffs[1]?.oldText).toContain('line16') + expect(diffs[1]?.newText).toContain('B') + // The two hunks are distinct sites, not one merged block. + expect(diffs[0]?.newText).not.toContain('B') + expect(diffs[1]?.newText).not.toContain('A') + }) + + it('identical before/after (a no-op) yields no hunks', () => { + expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([]) + }) + + it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => { + const diffs = computeHunkDiffs('f.txt', '', 'brand new\n') + expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }]) + }) + + it('a pure deletion of the whole file reports newText empty', () => { + const diffs = computeHunkDiffs('f.txt', 'gone\n', '') + expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }]) + }) + + it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => { + const diffs = computeHunkDiffs('f.txt', 'x', 'y') + // The marker line (starting with "\\") must never leak into a diff block. + expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }]) + expect(diffs[0]?.oldText).not.toContain('\\') + expect(diffs[0]?.newText).not.toContain('\\') + }) + + it('uses DIFF_CONTEXT (3) surrounding lines', () => { + expect(DIFF_CONTEXT).toBe(3) + const before = lines(20) + const after = before.replace('line10', 'CHANGED') + const [diff] = computeHunkDiffs('f.txt', before, after) + // 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side. + expect(diff?.oldText?.split('\n')).toHaveLength(7) + expect(diff?.newText.split('\n')).toHaveLength(7) + expect(diff?.oldText?.split('\n')[0]).toBe('line7') + }) +}) + +describe('diffsFromMeta (defensive narrowing)', () => { + // The narrowing accepts an opaque JsonValue; a malformed payload is not a + // statically-valid JsonValue, so route every case through one cast helper that + // mirrors how a hand-edited/older session log delivers arbitrary shapes. + const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined + const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] } + + it('narrows a well-formed { diffs } payload', () => { + expect(diffsFromMeta(m(good))).toEqual(good.diffs) + }) + + it('accepts a diff whose oldText is null (a create-style hunk)', () => { + const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] } + expect(diffsFromMeta(m(meta))).toEqual(meta.diffs) + }) + + it('rejects undefined / non-object / array meta', () => { + expect(diffsFromMeta(undefined)).toBeUndefined() + expect(diffsFromMeta(null)).toBeUndefined() + expect(diffsFromMeta(m('nope'))).toBeUndefined() + expect(diffsFromMeta(m([]))).toBeUndefined() + }) + + it('rejects a missing / empty / non-array diffs field', () => { + expect(diffsFromMeta(m({}))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined() + }) + + it('rejects a diffs array containing a malformed entry', () => { + expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined() + }) +}) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts new file mode 100644 index 0000000000..da5412530d --- /dev/null +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { fsHarness, waitForIdle } from './harness.ts' + +/** + * With-key smoke for the filesystem tools: a REAL model drives the REAL + * read/write/edit tools (over the real local backend + policy gate), and we + * verify the WORLD — the file on disk — not the agent's self-report. This is the + * "green units, broken product" guard: mocks prove the plumbing, only a real + * model proves the tools actually work end-to-end. Key-gated (self-skips without + * DEEPSEEK_API_KEY). + */ + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +const SYSTEM = 'You are a coding assistant. Use the write tool to create files, the read tool to inspect ' + + 'them, and the edit tool for literal replacements. Read a file before editing it. Keep replies terse.' + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => { + it('creates, reads, then edits a file — verified on disk', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-')) + ctx = await fsHarness(workdir, SYSTEM) + // agentLoop.create prepares a session with no cwd, so the provider default + // (config.cwd = workdir) is the workspace. + const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' }) + + agent.send([{ type: 'text', text: + 'Create a file named note.txt containing exactly the line: status: draft. ' + + 'Then read it back, then edit it to replace the literal word draft with final. ' + + 'Tell me when done.' }]) + await waitForIdle(ctx, agent) + + // Verify the WORLD: the edit landed on disk. + const content = await readFile(join(workdir, 'note.txt'), 'utf8') + expect(content).toContain('status: final') + expect(content).not.toContain('draft') + + // The log records real read/write/edit tool calls (not bash). + const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name) + expect(calls).toContain('write') + expect(calls).toContain('read') + expect(calls).toContain('edit') + }, 180_000) + + it('resolves a relative path against the per-session cwd (factory meta.cwd)', async () => { + // config.cwd is the harness workdir, but the agent's SESSION cwd is a + // different dir; the write must land in the SESSION dir, proving the tool + // passes the per-session cwd (not the backend default). + const configDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-cfg-')) + workdir = configDir + const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-')) + try { + ctx = await fsHarness(configDir, SYSTEM) + const handle = ctx.agents.create({ + agentId: AgentId('fs-e2e-cwd'), + sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), + meta: { cwd: sessionDir }, + agentOptions: { model: 'deepseek-v4-flash' }, + }) + handle.agent.send([{ type: 'text', text: + 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) + await waitForIdle(ctx, handle.agent) + + // The file is in the SESSION dir, not the config dir. + expect(await readFile(join(sessionDir, 'where.txt'), 'utf8')).toContain('here') + await expect(readFile(join(configDir, 'where.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(sessionDir, { recursive: true, force: true }) + } + }, 180_000) +}) diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts new file mode 100644 index 0000000000..61c163c28b --- /dev/null +++ b/packages/fs/tool-fs/tests/harness.ts @@ -0,0 +1,48 @@ +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' + +/** + * Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the + * DeepSeek adapter + the real fs provider + the read-before-write/edit policy + + * the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so + * importing it never re-registers another file's tests. + * + * `fsCwd` is the local backend's default base; a per-session cwd (set via a + * session header) overrides it, but this harness creates agents without a + * session cwd, so the provider default IS the workspace. `persona` is the + * deployment persona (the system-prompt plugin's per-context config). + */ +export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await ctx.plugin(LocalFileSystem, { cwd: fsCwd }) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + return ctx +} + +export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts new file mode 100644 index 0000000000..c0973197eb --- /dev/null +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -0,0 +1,410 @@ +/** + * Integration tests: the real local backend (`dsh-fs-local`) plus the model + * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` + * so nothing bypasses the tool registry. Two deployments: + * + * - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before- + * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. + * - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to + * its undefined default, so write/edit are unconditional. This proves the + * tool carries no dependency on the policy plugin. + * + * These verify the WORLD — files are read back from disk and asserted + * byte-for-byte — not the tool's self-report. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' + +let dir: string +let ctx: Context +let fiber: Awaited<ReturnType<Context['plugin']>> +// A stable session object stands in for an agent session (the file-state +// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to +// `undefined` and the backend falls back to its configured cwd (= `dir`). +const session = { header: {} } + +let callCounter = 0 +function call(name: string, args: unknown) { + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + agent: { session } as never, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +// -------------------------------------------------------------------------- +// DEFAULT deployment: the policy gate plugin is loaded. +// -------------------------------------------------------------------------- +describe('default deployment (with dsh-fs-policy)', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + fiber = await ctx.plugin(ToolFs) + }) + + describe('write → disk', () => { + it('creates a file with exactly the requested bytes', async () => { + const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n') + }) + + it('rejects overwriting an existing file without reading it first', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') + }) + + it('allows overwriting after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') + }) + + it('rejects a full overwrite when the file changed since the read (stale)', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + }) + + describe('read', () => { + it('returns line-numbered content', async () => { + await writeFile(join(dir, 'a.txt'), 'alpha\nbeta') + const result = await call('read', { file_path: 'a.txt' }) + expect(text(result)).toContain('1: alpha') + expect(text(result)).toContain('2: beta') + expect(text(result)).toContain('(End of file - total 2 lines)') + }) + + it('reports a binary file as an error', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) + const result = await call('read', { file_path: 'bin' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('paginates a multi-line file with offset/limit', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour') + const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 }) + expect(text(result)).toContain('2: two') + expect(text(result)).toContain('3: three') + expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) + }) + + describe('edit → disk', () => { + it('applies a unique literal replacement after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an edit before any read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => { + // A file with more lines than the read window; read only the first line. + const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`) + await writeFile(join(dir, 'a.txt'), lines.join('\n')) + const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + expect(read.isError).toBe(false) + expect(text(read)).toContain('(Showing lines 1-1 of 20') + + // Editing a line OUTSIDE the window is authorized because the file is unchanged. + const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n')) + }) + + it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects an ambiguous match without replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') + }) + + it('replaces all matches with replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') + }) + + it('supports a full write→edit cycle without an intervening read', async () => { + await call('write', { file_path: 'a.txt', content: 'one two' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') + }) + }) + + describe('the gate records only through the events (no method coupling)', () => { + it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + // Reach AROUND the tool — an explicit escape hatch for non-tool consumers. + await ctx.fs.readText(await ctx.fs.resolve('a.txt')) + // The model-facing edit still rejects: the read did not emit fs/observed. + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + }) + + describe('stat budget', () => { + it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const statSpy = vi.spyOn(ctx.fs, 'stat') + + // read: exactly one stat (type + size routing + observed version). + await call('read', { file_path: 'a.txt' }) + expect(statSpy).toHaveBeenCalledTimes(1) + + // edit (guarded, after the read): the gate supplies vObserved; the tool + // does not stat to manufacture a basis. CAS happens in editText's lock. + statSpy.mockClear() + const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(edited.isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + + // write (guarded replace, after the edit refreshed observed state): zero stat. + statSpy.mockClear() + const written = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(written.isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + statSpy.mockRestore() + }) + }) +}) + +// -------------------------------------------------------------------------- +// BARE deployment: the tool suite WITHOUT the policy gate. +// -------------------------------------------------------------------------- +describe('bare provider (no dsh-fs-policy)', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + fiber = await ctx.plugin(ToolFs) + }) + + it('read works (it never needed policy)', async () => { + await writeFile(join(dir, 'a.txt'), 'alpha\nbeta') + const result = await call('read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('1: alpha') + }) + + it('write unconditionally creates a new file', async () => { + const result = await call('write', { file_path: 'new.txt', content: 'fresh' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('write unconditionally OVERWRITES an existing unread file', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + const result = await call('write', { file_path: 'a.txt', content: 'clobbered' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered') + }) + + it('edit unconditionally edits an UNREAD existing file', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => { + const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + }) + + it('neither write nor edit stats in the tool on the bare path', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const statSpy = vi.spyOn(ctx.fs, 'stat') + expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false) + expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + statSpy.mockRestore() + }) +}) + +// -------------------------------------------------------------------------- +// Per-session cwd: a relative file_path resolves against the CALLING session's +// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd — +// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression +// this guards: before the seam fix the tool passed no cwd, so a relative write +// landed in config.cwd instead of the session dir. +// -------------------------------------------------------------------------- +describe('per-session cwd', () => { + let sessionDir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-')) + sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir + await ctx.plugin(FsPolicy) + fiber = await ctx.plugin(ToolFs) + }) + afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) }) + + const callIn = (sessionObj: object, name: string, args: unknown) => + ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + agent: { session: sessionObj } as never, + }) + + it('writes a relative path into the SESSION cwd, not config.cwd', async () => { + const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' }) + expect(result.isError).toBe(false) + // Verify the WORLD: the file is in the session dir, and NOT in config.cwd. + expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi') + await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('read + edit both resolve against the session cwd (end-to-end)', async () => { + // ONE session object across both calls — observed-state keys by owner + // identity, so read must record under the same owner the edit reads. + const session = { header: { cwd: sessionDir } } + await writeFile(join(sessionDir, 'code.txt'), 'alpha') + expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false) + const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' }) + expect(edited.isError).toBe(false) + expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta') + }) +}) + +// -------------------------------------------------------------------------- +// Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract — +// all through ctx.tools.execute() against the REAL backend + policy. +// -------------------------------------------------------------------------- +describe('signal, concurrency, and the fs/observed contract', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + fiber = await ctx.plugin(ToolFs) + }) + + const session = { header: {} } + const callSig = (signal: AbortSignal, name: string, args: unknown) => + ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal }) + const callOwned = (name: string, args: unknown) => + ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) + + it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) + expect(read.isError).toBe(true) + expect(read.error).toMatchObject({ code: 'FS_ABORTED' }) + + const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) + expect(write.isError).toBe(true) + expect(write.error).toMatchObject({ code: 'FS_ABORTED' }) + await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + + // Read first (un-aborted, SAME session owner) so the edit clears the + // observation gate; then the aborted edit fails on the signal, not on + // FS_NOT_OBSERVED. + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged + }) + + it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => { + await writeFile(join(dir, 'a.txt'), 'base value here') + // One read establishes the observed version both edits guard against; then + // race two edits so both carry the SAME observed version (the barrier). + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + const [one, two] = await Promise.all([ + callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }), + callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }), + ]) + const errors = [one, two].filter(r => r.isError) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + // The world is consistent: exactly one edit landed. + const onDisk = await readFile(join(dir, 'a.txt'), 'utf8') + expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) + }) + + it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => { + // fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing + // listener cannot roll the write back — it only turns the tool result into + // isError. The file must still carry the written bytes. + ctx.on('fs/observed', () => { throw new Error('recording bug') }) + const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' }) + expect(result.isError).toBe(true) + expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable') + }) +}) diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts new file mode 100644 index 0000000000..c23ad79170 --- /dev/null +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -0,0 +1,116 @@ +/** + * Cordis-free tests for the line-windowing module: offset/limit windows, byte + * caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the + * capped line buffer for newline-free giant lines — all over an async-iterable + * of decoded text chunks (so one code path serves whole-file and streamed reads). + */ + +import { describe, expect, it } from 'vitest' +import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' +import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs' + +const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } +const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS } + +/** Yield `text` as one chunk (whole-file read shape). */ +async function* whole(text: string): AsyncIterable<string> { + yield text +} + +/** Yield `text` split into fixed-size chunks (streamed read shape). */ +async function* chunked(text: string, size: number): AsyncIterable<string> { + for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size) +} + +describe('buildWindow', () => { + it('numbers lines and reports total for a whole-file read', async () => { + const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f') + expect(result.lines).toEqual([ + { number: 1, text: 'one' }, + { number: 2, text: 'two' }, + { number: 3, text: 'three' }, + ]) + expect(result.totalLines).toBe(3) + expect(result.truncatedByBytes).toBe(false) + }) + + it('applies offset/limit', async () => { + const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f') + expect(result.lines.map(l => l.number)).toEqual([2, 3]) + expect(result.totalLines).toBe(4) + }) + + it('strips CRLF', async () => { + const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f') + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('truncates an over-long line', async () => { + const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f') + expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`) + }) + + it('caps output bytes and reports truncatedByBytes', async () => { + const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n') + const result = await buildWindow(whole(big), READ_ALL, 'f') + expect(result.truncatedByBytes).toBe(true) + }) + + it('reads an empty file at offset 1 as zero lines', async () => { + const result = await buildWindow(whole(''), READ_ALL, 'f') + expect(result.lines).toEqual([]) + expect(result.totalLines).toBe(0) + }) + + it('rejects an offset past EOF', async () => { + await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('flushes a final line with no trailing newline', async () => { + const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f') + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('handles a trailing newline (no dangling empty line)', async () => { + const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f') + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + expect(result.totalLines).toBe(2) + }) + + describe('caps are per-request (the plugin config reaches the window)', () => { + it('truncates lines at a custom maxLineLength and names it in the suffix', async () => { + const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f') + expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)') + }) + + it('caps output at a custom maxBytes', async () => { + const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f') + expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb']) + expect(result.truncatedByBytes).toBe(true) + }) + }) + + describe('chunked input (streamed read shape)', () => { + it('windows identically when text arrives in small chunks', async () => { + const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f') + expect(result.lines).toEqual([{ number: 2, text: 'two' }]) + expect(result.totalLines).toBe(3) + }) + + it('caps a newline-free giant line split across chunks without unbounded buffering', async () => { + const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f') + expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`) + }) + + it('caps output bytes mid-stream', async () => { + const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n') + const result = await buildWindow(chunked(big, 512), READ_ALL, 'f') + expect(result.truncatedByBytes).toBe(true) + }) + + it('flushes a final newline-terminated line across a chunk boundary', async () => { + const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f') + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts new file mode 100644 index 0000000000..53f912cce2 --- /dev/null +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -0,0 +1,566 @@ +/** + * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the + * REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy + * collaborator, per the prefer-the-real-implementation rule) over a fake + * `ctx.fs` provider, so they verify schemas, argument validation, result + * formatting, FsError→isError propagation, and that each tool dispatches the + * `fs/*` waterfalls + records observed-state through the gate (read authorizes a + * later edit) — not just that it moved bytes. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' +import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' + +/** An in-memory fake provider; a test can arm a rejection on any primitive. */ +class FakeFs extends FileSystem { + files = new Map<string, string>() + rejectWith?: FsError + writeIntents: (FsWriteIntent | undefined)[] = [] + editIntents: ({ version: FsVersion } | undefined)[] = [] + + private throwIfArmed(): void { + if (this.rejectWith) throw this.rejectWith + } + + override async resolve(path: string): Promise<FsTarget> { + return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } + } + override async stat(target: FsTarget): Promise<FsInfo | undefined> { + this.throwIfArmed() + const content = this.files.get(target.targetKey) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } + override async readText(target: FsTarget): Promise<string> { + return this.files.get(target.targetKey) ?? '' + } + override async streamText(target: FsTarget): Promise<AsyncIterable<string>> { + const content = this.files.get(target.targetKey) ?? '' + return (async function* () { yield content })() + } + override async listDir(_target: FsTarget): Promise<FsDirEntry[]> { + return [] + } + override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> { + this.throwIfArmed() + this.writeIntents.push(expected) + const before = this.files.get(target.targetKey) ?? null + this.files.set(target.targetKey, content) + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } + } + override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> { + this.throwIfArmed() + this.editIntents.push(expected) + const content = this.files.get(target.targetKey) ?? '' + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { version: FsVersion('v3'), before: content, after } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + const fs = ctx.fs as FakeFs + return { ctx, fs } +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: object) { + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent: agent as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('registration', () => { + it('registers read, write, and edit', async () => { + const { ctx } = await setup() + expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) + }) + + it('registers prompt sections for each tool', async () => { + const { ctx } = await setup() + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Use the read tool') + expect(prompt).toContain('Use the write tool') + expect(prompt).toContain('Use the edit tool') + }) + + it('stays pending until ctx.fs exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolFs) // no fs provider + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(FsPolicy) + const fiber = await ctx.plugin(ToolFs) + // Each tool contributes BOTH a schema and a prompt section; disposal must + // withdraw both, not just the schemas. + expect(ctx.tools.schemas()).toHaveLength(3) + const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write']) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + // Only the system-prompt plugin's own built-in sections remain. + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity']) + }) +}) + +describe('read tool', () => { + it('formats line-numbered content with a footer', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'hello\nworld') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe(`<path>/abs/a.txt</path> +<type>file</type> +<content> +1: hello +2: world + +(End of file - total 2 lines) +</content>`) + }) + + it('rejects a non-positive offset via arg validation', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('offset must be a positive integer') + }) + + it('rejects a fractional or NaN offset, and a zero/negative limit', async () => { + const { ctx } = await setup() + for (const args of [ + { file_path: 'a.txt', offset: 1.5 }, + { file_path: 'a.txt', offset: Number.NaN }, + { file_path: 'a.txt', limit: 0 }, + { file_path: 'a.txt', limit: -3 }, + ]) { + const result = await call(ctx, 'read', args) + expect(result.isError, JSON.stringify(args)).toBe(true) + expect(text(result)).toMatch(/must be a positive integer/) + } + }) + + it('rejects a limit above the cap', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('less than or equal to 2000') + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: ' ' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('records observed state so a follow-up edit by the same session is authorized', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', 'hello') + expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false) + const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session }) + expect(edited.isError).toBe(false) + expect(fs.editIntents).toEqual([{ version: 'v1' }]) + }) + + it('propagates FS_NOT_FOUND for an absent file', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'missing.txt' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a non-regular target', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:d', '') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' }) + const result = await call(ctx, 'read', { file_path: 'd' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('streams a large file (size at/above the cap) instead of reading whole', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:big.txt', 'alpha\nbeta') + const readSpy = vi.spyOn(fs, 'readText') + const streamSpy = vi.spyOn(fs, 'streamText') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE }) + const result = await call(ctx, 'read', { file_path: 'big.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('1: alpha') + expect(streamSpy).toHaveBeenCalled() + expect(readSpy).not.toHaveBeenCalled() + }) + + it('streams when the backend reports no size (never buffers a size-less file)', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'alpha') + const streamSpy = vi.spyOn(fs, 'streamText') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(streamSpy).toHaveBeenCalled() + }) + + it('surfaces a byte-capped read as a truncated footer', async () => { + const { ctx, fs } = await setup() + // Many long lines so the window hits the byte cap before EOF. + fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const result = await call(ctx, 'read', { file_path: 'big.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Output capped.') + }) + +}) + +describe('formatReadOutput footer variants', () => { + const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 } + + it('reports a byte-capped read', () => { + const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) + expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)') + }) + + it('reports a more-remaining page', () => { + const out = formatReadOutput('/f', { ...base, totalLines: 99 }) + expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)') + }) + + it('reports end-of-file', () => { + expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)') + }) + + it('renders an empty file as just the footer', () => { + const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 }) + expect(out).toContain('(End of file - total 0 lines)') + expect(out).not.toContain(': ') + }) +}) + +describe('write tool', () => { + it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => { + const { ctx, fs } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Created file') + expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('propagates a backend FsError as an isError result carrying its code', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' }) + }) +}) + +describe('edit tool', () => { + it('formats a single-replacement success after a read', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', 'a') + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session }) + expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') + }) + + it('formats the replace_all success message distinctly', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', 'a a a') + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session }) + expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.') + }) + + it('rejects identical old/new strings', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('must differ') + }) + + it('rejects an empty old_string', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('old_string must be a non-empty string') + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'hello') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('tool-owned presentation (pure presentCall)', () => { + // presentCall is a pure display function of args (no I/O); it drives the ACP + // card's title/kind and the `locations` an editor follows along to. + const presentCall = async (name: string, args: unknown) => { + const { ctx } = await setup() + return ctx.tools.get(name)?.presentCall?.(args) + } + + it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => { + expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({ + card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', + locations: [{ path: 'src/a.ts', line: 12 }], + }) + }) + + it('read: bare title and line-1 location when offset/limit are unset', async () => { + expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({ + card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], + }) + }) + + it('read: "from line N" window when only offset is set', async () => { + expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({ + card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }], + }) + }) + + it('write: diff card (new-file style, oldText null), location', async () => { + expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({ + card: 'diff', title: 'Write out.txt', + diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }], + locations: [{ path: 'out.txt' }], + }) + }) + + it('read: a limit with no offset windows from line 1', async () => { + expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({ + card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], + }) + }) + + it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => { + // presentCall runs on replay of raw logged args, which parseEditArgs does not + // gate — an empty old_string must still produce a valid diff (oldText null). + expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({ + card: 'diff', title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }], + locations: [{ path: 'a.txt' }], + }) + }) +}) + +describe('result-time contextual diff (meta + presentResult)', () => { + // An edit records the applied contextual hunk on `tool/result` meta, and the + // tool's presentResult narrows it back into a `diff` result card the bridge + // renders. Drive execute end-to-end so the meta is the REAL computed hunk. + const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n' + + it('edit: execute attaches the applied hunk as meta { diffs }', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toEqual({ + diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }], + }) + }) + + it('edit: presentResult turns the meta into a diff result card', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session }) + const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result) + expect(view).toEqual({ + card: 'diff', title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }], + }) + }) + + it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) + }) + + it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { + // A create has no prior content (no `meta`), yet the completed card must be a + // `diff` — an ACP tool_call_update.content REPLACES the call's content, so a + // non-diff result would clobber the pending new-file diff. The whole-file diff + // is derived from the args (oldText:null), replay-safe. + const { ctx } = await setup() + const session = { header: {} } + const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toBeUndefined() + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) + }) + + it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', 'same\n') + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toBeUndefined() + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) + }) + + it('presentResult returns undefined on an error result (nothing applied)', async () => { + const { ctx } = await setup() + const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true } + expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined() + expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined() + }) + + it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => { + // edit has no whole-file fallback (only a literal replacement), so a malformed + // meta yields the generic "updated successfully" rendering. + const { ctx } = await setup() + const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } } + expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined() + }) + + it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => { + // write always renders a diff card so the completed update can't clobber the + // pending diff with the model-facing text; a malformed meta falls back to the + // args-derived whole-file diff, same as a create. + const { ctx } = await setup() + const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } } + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] }) + }) +}) + +describe('read caps are plugin config', () => { + async function setupWith(config: ToolFs.Config) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs, config) + return { ctx, fs: ctx.fs as FakeFs } + } + + it('a configured readLimit is both the default and the cap, and the schema names it', async () => { + const { ctx, fs } = await setupWith({ readLimit: 2 }) + fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)') + const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 }) + expect(overCap.isError).toBe(true) + expect(text(overCap)).toContain('less than or equal to 2') + const readSchema = ctx.tools.schemas().find(s => s.name === 'read') + expect(JSON.stringify(readSchema)).toContain('Defaults to 2.') + }) + + it('a configured readMaxLineLength truncates lines at the configured length', async () => { + const { ctx, fs } = await setupWith({ readMaxLineLength: 4 }) + fs.files.set('key:a.txt', 'abcdefgh') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)') + }) + + it('a configured readMaxBytes caps the window at the configured bytes', async () => { + const { ctx, fs } = await setupWith({ readMaxBytes: 9 }) + fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('Output capped.') + expect(text(result)).not.toContain('cccc') + }) + + it('a configured readStreamMinSize routes smaller files to the streaming path', async () => { + const { ctx, fs } = await setupWith({ readStreamMinSize: 5 }) + fs.files.set('key:a.txt', 'alpha\nbeta') + const readSpy = vi.spyOn(fs, 'readText') + const streamSpy = vi.spyOn(fs, 'streamText') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(streamSpy).toHaveBeenCalled() + expect(readSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['readLimit', { readLimit: 0 }], + ['readLimit', { readLimit: 2.5 }], + ['readMaxLineLength', { readMaxLineLength: -1 }], + ['readMaxBytes', { readMaxBytes: Number.NaN }], + ['readStreamMinSize', { readStreamMinSize: 0 }], + ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`)) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in ToolFs).toBe(false) + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json new file mode 100644 index 0000000000..f0133b1d2b --- /dev/null +++ b/packages/fs/tool-fs/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../fs" }, + { "path": "../fs-policy" } + ] +} diff --git a/packages/hooks/README.md b/packages/hooks/README.md new file mode 100644 index 0000000000..2bdb65d5bf --- /dev/null +++ b/packages/hooks/README.md @@ -0,0 +1,11 @@ +# hooks/ — hook bridges + shared protocol + +The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on. + +| Package | Role | Shape | +|---|---|---| +| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) | +| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | +| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | + +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md new file mode 100644 index 0000000000..64ed64b000 --- /dev/null +++ b/packages/hooks/hook-protocol/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-hook-protocol + +The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol. + +Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs. + +## What's shared (here) vs. per-dialect (the bridges) + +| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | +|---|---|---| +| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) | +| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | +| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | +| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | +| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation | + +## Primitives + +- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). +- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. +- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. + +## `hook/*` session events + +Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). + +Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC. + +## Input rewrite is parsed but not honored + +`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json new file mode 100644 index 0000000000..2220220769 --- /dev/null +++ b/packages/hooks/hook-protocol/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-hook-protocol", + "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts new file mode 100644 index 0000000000..7cf97feca9 --- /dev/null +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -0,0 +1,166 @@ +/** + * Parse a finished hook command's process outcome (exit code + stdout + stderr) + * into the dialect-neutral {@link HookOutput} both bridges map from. + * + * The exit-code contract is shared by Claude Code and Codex: + * - exit 0 → success; if stdout is structured JSON, parse it; else the plain + * stdout is available to the bridge (some events treat it as `additionalContext`). + * - exit 2 → BLOCKING error; stderr is the block reason fed back to the model. + * We surface this as `decision: 'block'` with `reason = stderr` so a bridge + * needs no separate exit-code branch — the neutral output already says "block". + * - other → non-blocking error; recorded (exitCode + stderr) but no decision. + * + * Structured-stdout fields are a SUPERSET across dialects (CC is richest); we + * parse every field we recognize and leave it to the bridge to honor only the + * subset meaningful for its dialect/hook point (Codex, e.g., ignores + * `allow`/`ask`/`updatedInput`). + * + * @module @deepseek-ai/dsh-hook-protocol/codec + */ + +import type { HookOutput } from './types.ts' + +/** The exit code a hook uses to signal a blocking error (stderr → model). */ +const BLOCKING_EXIT_CODE = 2 + +/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */ +function str(obj: Record<string, unknown>, key: string): string | undefined { + const v = obj[key] + return typeof v === 'string' ? v : undefined +} + +/** Read a boolean field, or `undefined` if absent/wrong type. */ +function bool(obj: Record<string, unknown>, key: string): boolean | undefined { + const v = obj[key] + return typeof v === 'boolean' ? v : undefined +} + +/** A plain (non-null, non-array) object, or `undefined`. */ +function obj(value: unknown): Record<string, unknown> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : undefined +} + +/** + * The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference + * schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput. + * permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and + * ignored here (it must not become a real blocking decision). + */ +function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] { + return value === 'approve' || value === 'block' ? value : undefined +} + +/** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */ +function permissionDecisionOf(value: string | undefined): HookOutput['decision'] { + return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined +} + +/** + * Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr` + * are the captured streams; `exitCode` is the process exit (`undefined` when the + * hook could not be spawned at all). Pure and total — never throws; malformed + * JSON on a 0 exit is treated as "no structured output" (the plain stdout is + * still on the bridge to use), matching both reference engines' lenient parse of + * non-JSON stdout. + * + * `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`). + * The reference schemas key the `hookSpecificOutput` block by `hookEventName`, + * so a block whose `hookEventName` names a DIFFERENT event is malformed and its + * event-scoped fields (`permissionDecision`/`permissionDecisionReason`/ + * `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a + * `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still + * surfaced (for the log/diagnostics), and the event-agnostic top-level fields + * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`) + * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the + * block as-is — a caller that doesn't key by event opts out of the check. + */ +export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput { + const trimmedErr = stderr.trim() + const trimmedOut = stdout.trim() + // Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the + // protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit + // additionalContext), so the bridge needs it even when there's no JSON. + const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut } + + // Exit 2 is a blocking error in both dialects: stderr is the reason. Surface + // it as a `block` decision so the bridge maps it uniformly with a structured + // `decision:'block'` — the exit code and the JSON channel converge here. + if (exitCode === BLOCKING_EXIT_CODE) { + output.decision = 'block' + if (trimmedErr.length > 0) output.reason = trimmedErr + } + + // Structured stdout is only consulted on a clean (0) exit; on a blocking exit + // the stderr channel is authoritative. A non-zero/undefined exit other than 2 + // carries no decision (the bridge records it as a non-blocking error). + if (exitCode === 0) { + // Only attempt JSON when stdout looks like a JSON object — matches the + // reference engines, which treat other stdout as plain text, not an error. + if (trimmedOut.startsWith('{')) { + let parsed: Record<string, unknown> | undefined + try { + parsed = obj(JSON.parse(trimmedOut)) + } catch { + // Malformed JSON on a clean exit = no structured output (lenient, as the + // reference engines are). The plain stdout remains the bridge's to use. + parsed = undefined + } + if (parsed) applyStructured(output, parsed, expectedEventName) + } + } + + return output +} + +/** + * Fold a parsed structured-stdout object into `output` (mutates in place). + * `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput` + * block: a block whose `hookEventName` names a different event — OR omits it — has + * its event-scoped fields discarded (any present `hookEventName` is still recorded). + */ +function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void { + const cont = bool(parsed, 'continue') + if (cont !== undefined) output.continue = cont + const stopReason = str(parsed, 'stopReason') + if (stopReason !== undefined) output.stopReason = stopReason + const sysMsg = str(parsed, 'systemMessage') + if (sysMsg !== undefined) output.systemMessage = sysMsg + + // Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are + // invalid per both schemas) + its `reason`. + const topDecision = topLevelDecisionOf(str(parsed, 'decision')) + if (topDecision !== undefined) output.decision = topDecision + const topReason = str(parsed, 'reason') + if (topReason !== undefined) output.reason = topReason + + // hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The + // permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision; + // additionalContext and updatedInput live here too. + const hso = obj(parsed.hookSpecificOutput) + if (hso) { + const eventName = str(hso, 'hookEventName') + // Always surface the discriminator (for the log/diagnostics), even on a + // mismatch — the record should show what the malformed block claimed. + if (eventName !== undefined) output.hookEventName = eventName + // The schemas key this block by event: when a caller passes the firing event + // (`expectedEventName`), the block's `hookEventName` MUST name it. A different + // name — or a MISSING one — is malformed under the keyed schema, so discard the + // event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a + // discriminator-less block silently apply PreToolUse-scoped permission fields to + // whatever event is firing). A caller that passes no expectedEventName opts out + // of the check (applies the block as-is). + if (expectedEventName !== undefined && eventName !== expectedEventName) { + return + } + const permission = permissionDecisionOf(str(hso, 'permissionDecision')) + if (permission !== undefined) output.decision = permission + const permissionReason = str(hso, 'permissionDecisionReason') + if (permissionReason !== undefined) output.reason = permissionReason + const addCtx = str(hso, 'additionalContext') + if (addCtx !== undefined) output.additionalContext = addCtx + const updated = obj(hso.updatedInput) + if (updated !== undefined) output.updatedInput = updated + } +} diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts new file mode 100644 index 0000000000..5e14f964fb --- /dev/null +++ b/packages/hooks/hook-protocol/src/events.ts @@ -0,0 +1,107 @@ +/** + * Append helpers for the log-only `hook/*` session events — the durable record + * that a hook ran and what it decided. Thin wrappers over `session.append` so a + * bridge does not hand-build the payloads (and so the `turn`-enclosure + + * invoked/result pairing stay consistent across both bridges). + * + * `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no + * `surfaceOp` and append with no surface intent — but, like every event, they + * must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed + * event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/ + * `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the + * exception (its injected `context/message` is the durable evidence instead), so + * a bridge does NOT write `hook/*` for session-start — see the hooks RFC. + * + * @module @deepseek-ai/dsh-hook-protocol/events + */ + +import type { Session } from '@deepseek-ai/dsh-session' +import type { HookDialect, HookOutput } from './types.ts' + +/** What identifies a hook invocation across its invoked/result pair. */ +export interface HookInvocation { + /** The open turn the invocation lives inside. */ + turn: number + /** The hook point (`PreToolUse`, `Stop`, …). */ + point: string + /** The bridge dialect that ran it. */ + dialect: HookDialect + /** A stable id correlating the invoked event with its result. */ + handlerId: string + /** The matcher-group pattern that selected it (absent for match-all). */ + matcher?: string +} + +/** The decided outcome half of the pair. */ +export interface HookResultRecord { + turn: number + point: string + handlerId: string + /** + * The decoded outcome the run produced. {@link appendHookResult} derives the + * durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared + * event's semantics live here, in the lib that declares it, not per-bridge. + */ + output: HookOutput + /** + * Character cap for the derived `stderrSummary`. The bound is the bridge's + * to own (its `stderrSummaryMaxChars` config) and is passed in explicitly — + * {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default. + */ + stderrSummaryMaxChars: number + /** Wall-clock duration of the run (from `runHook`) — durable audit timing. */ + durationMs: number +} + +/** + * The reference default for {@link HookResultRecord.stderrSummaryMaxChars} + * (both bridges' config default). It lives here, once, next to the truncation + * rule it bounds, so the bridges cannot drift apart on the shared event's + * default cap. + */ +export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500 + +/** + * Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed, + * `undefined` when empty, cut at `maxChars` with an ellipsis when over. The + * bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns + * the config default and passes it in. + */ +export function summarizeStderr(stderr: string, maxChars: number): string | undefined { + const t = stderr.trim() + if (t.length === 0) return undefined + return t.length > maxChars ? t.slice(0, maxChars) + '…' : t +} + +/** Append a `hook/invoked` provenance event to `session`. */ +export function appendHookInvoked(session: Session, invocation: HookInvocation): void { + session.append('hook/invoked', { + turn: invocation.turn, + point: invocation.point, + dialect: invocation.dialect, + handlerId: invocation.handlerId, + ...invocation.matcher !== undefined ? { matcher: invocation.matcher } : {}, + }) +} + +/** + * Append a `hook/result` outcome event to `session` (pairs with a prior + * `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's + * parsed decision, else `'stop'` when it asked to halt (`continue: false`), + * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to + * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode` + * is omitted when the hook never ran. + */ +export function appendHookResult(session: Session, record: HookResultRecord): void { + const { output } = record + const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars) + session.append('hook/result', { + turn: record.turn, + point: record.point, + handlerId: record.handlerId, + decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), + ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {}, + ...stderrSummary !== undefined ? { stderrSummary } : {}, + durationMs: record.durationMs, + }) +} diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts new file mode 100644 index 0000000000..fc9f658e6f --- /dev/null +++ b/packages/hooks/hook-protocol/src/index.ts @@ -0,0 +1,40 @@ +/** + * `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex + * hook wire protocol. NOT a cordis plugin: it registers nothing and injects + * nothing. It is a LIBRARY of dialect-neutral primitives the two bridge plugins + * (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the + * identical halves of the protocol: + * + * - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect). + * - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash` + * (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral + * {@link HookOutput}. + * - {@link mergeHookOutputs} — fold multiple matched hooks into one + * most-restrictive {@link MergedHookOutcome} (deny > ask > allow). + * - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*` + * session-event helpers (declaration-merged into `SessionEventMap`); + * `appendHookResult` derives the durable `decision`/`stderrSummary` from the + * {@link HookOutput} so the shared event's semantics live in one place. + * + * Each bridge owns what genuinely DIFFERS: building the per-event stdin payload + * (CC vs Codex field sets), the dialect's env/substitution, and mapping the + * neutral outcome onto the harness's seam-specific typed Decisions. + * + * @module @deepseek-ai/dsh-hook-protocol + */ + +export type { + CommandHook, + HookDialect, + HookOutput, + MatcherGroup, + MatcherMode, +} from './types.ts' +export { matchesMatcher } from './matcher.ts' +export { parseHookOutput } from './codec.ts' +export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' +export type { RunHookOptions, RunHookResult } from './runner.ts' +export { mergeHookOutputs } from './merge.ts' +export type { MergedDecision, MergedHookOutcome } from './merge.ts' +export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts' +export type { HookInvocation, HookResultRecord } from './events.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts new file mode 100644 index 0000000000..ee1dd324b3 --- /dev/null +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -0,0 +1,54 @@ +/** + * The matcher primitive shared by both hook dialects: decide whether a matcher + * pattern selects a given query (a tool name, a session source, …). + * + * The two dialects differ ONLY in how a non-empty pattern is interpreted, so + * that single axis is the {@link MatcherMode} parameter: + * - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe = + * exact-match alternation, e.g. `Edit|Write`); anything else is a regex. + * - `codex`: every pattern is an unanchored regex (no literal fast path). + * + * Both treat an absent / empty / `'*'` pattern as match-all, and both treat an + * invalid regex as a non-match: a broken matcher selects nothing rather than + * throwing into the loop. This is SILENT — the boolean return cannot distinguish + * "did not match" from "failed to compile", so a typo'd pattern (e.g. `[`) + * quietly disables that matcher with no warning. Surfacing bad config would need + * a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`). + * + * @module @deepseek-ai/dsh-hook-protocol/matcher + */ + +import type { MatcherMode } from './types.ts' + +/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ +function isMatchAll(matcher: string | undefined): boolean { + return matcher === undefined || matcher === '' || matcher === '*' +} + +/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ + +/** + * Whether `matcher` selects `query` under the given dialect {@link MatcherMode}. + * Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal + * pattern exact-matches the query (splitting `|` into alternatives); every other + * `claude` pattern and ALL `codex` patterns are tested as an unanchored regex. + * An invalid regex matches nothing (never throws). + */ +export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { + if (isMatchAll(matcher)) return true + // matcher is a non-empty string past the match-all guard. + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + return pattern.split('|').includes(query) + } + try { + return new RegExp(pattern).test(query) + } catch { + // Invalid regex: a broken matcher selects nothing rather than throwing into + // the agent loop. This is silent — callers get `false`, indistinguishable + // from a genuine non-match, so a typo'd pattern quietly disables the matcher. + // Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)). + return false + } +} diff --git a/packages/hooks/hook-protocol/src/merge.ts b/packages/hooks/hook-protocol/src/merge.ts new file mode 100644 index 0000000000..1e53dbaaea --- /dev/null +++ b/packages/hooks/hook-protocol/src/merge.ts @@ -0,0 +1,116 @@ +/** + * Merge the outcomes of MULTIPLE hooks that matched one hook point into a single + * most-restrictive {@link MergedHookOutcome}. Both reference engines run matched + * hooks concurrently and fold their results; the precedence rules here are the + * intersection both dialects agree on (and the strictest interpretation where + * they differ), so a bridge gets one decision to map onto its seam: + * + * - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an + * `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter + * appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the + * rule degenerates correctly for it.) + * - **halt is sticky**: the first hook with `continue:false` sets `stop` and its + * `stopReason`. + * - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's + * `join_text_chunks`), so the model sees every objection, not just the first. + * - **context accumulates**: `additionalContext` from every hook is collected in + * order (CC concatenates; Codex keeps them as separate developer messages — + * either way the bridge gets the ordered list). + * - **systemMessages accumulate** likewise. + * + * @module @deepseek-ai/dsh-hook-protocol/merge + */ + +import type { HookOutput } from './types.ts' + +/** The single decision a hook point resolves to after merging all matched hooks. */ +export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none' + +/** The folded outcome of every hook that matched one point. */ +export interface MergedHookOutcome { + /** + * The most-restrictive permission decision across all hooks (`deny` > `ask` > + * `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to + * `deny`; `approve`/`allow` both fold to `allow`. + */ + decision: MergedDecision + /** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */ + reason?: string + /** `true` when any hook asked to halt (`continue:false`). */ + stop: boolean + /** The first halting hook's `stopReason`, when one halted. */ + stopReason?: string + /** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */ + additionalContext: string[] + /** Every hook's `systemMessage`, in hook order. */ + systemMessages: string[] +} + +/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */ +function rank(decision: HookOutput['decision']): number { + switch (decision) { + case 'deny': case 'block': return 3 + case 'ask': return 2 + case 'approve': case 'allow': return 1 + default: return 0 // no decision + } +} + +/** Collapse a ranked decision back to the merged enum. */ +function decisionForRank(maxRank: number): MergedDecision { + switch (maxRank) { + case 3: return 'deny' + case 2: return 'ask' + case 1: return 'allow' + default: return 'none' + } +} + +/** + * Fold `outputs` (the results of every hook that matched a point, in hook order) + * into one {@link MergedHookOutcome} by the precedence rules above. An empty list + * yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the + * caller treats that as "no hook had anything to say". + */ +export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome { + let maxRank = 0 + // Reasons collected PER RANK, so the merged reason can be the one explaining + // the WINNING decision (a deny-winning outcome surfaces deny reasons; an + // ask-winning outcome surfaces ask reasons). An `allow`'s reason is never an + // objection the model needs, so rank 1 collects none. + const reasonsByRank = new Map<number, string[]>() + let stop = false + let stopReason: string | undefined + const additionalContext: string[] = [] + const systemMessages: string[] = [] + + for (const out of outputs) { + const r = rank(out.decision) + if (r > maxRank) maxRank = r + if ((r === 3 || r === 2) && out.reason !== undefined && out.reason.length > 0) { + const list = reasonsByRank.get(r) ?? [] + list.push(out.reason) + reasonsByRank.set(r, list) + } + if (out.continue === false && !stop) { + stop = true + if (out.stopReason !== undefined) stopReason = out.stopReason + } + if (out.additionalContext !== undefined && out.additionalContext.length > 0) { + additionalContext.push(out.additionalContext) + } + if (out.systemMessage !== undefined && out.systemMessage.length > 0) { + systemMessages.push(out.systemMessage) + } + } + + const reasons = reasonsByRank.get(maxRank) ?? [] + return { + decision: decisionForRank(maxRank), + ...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {}, + stop, + ...stopReason !== undefined ? { stopReason } : {}, + additionalContext, + systemMessages, + } +} diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts new file mode 100644 index 0000000000..f5a892c468 --- /dev/null +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -0,0 +1,113 @@ +/** + * Run one configured command hook through the `ctx.bash` executor seam and parse + * its outcome into a {@link HookOutput}. This is where the wire protocol's + * EXECUTION half lives: feed the hook its JSON payload on stdin, hand it the + * dialect's env vars, honor its timeout, capture stdout/stderr/exit, and decode. + * + * It runs hooks through `ctx.bash` (not a bespoke `spawn`) deliberately — the + * bash seam already provides the scrubbed-but-overridable env, process-group + * kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields + * are the trusted-plugin surface (added for exactly this) that a hook bridge — + * an in-process plugin, not model output — is allowed to use. + * + * @module @deepseek-ai/dsh-hook-protocol/runner + */ + +import type { BashExecutor } from '@deepseek-ai/dsh-bash' +import { parseHookOutput } from './codec.ts' +import type { CommandHook, HookOutput } from './types.ts' + +/** + * The reference default per-hook timeout, in ms (10 minutes) — the value both + * Claude Code and Codex apply to a hook whose config sets no `timeout`. It + * lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs` + * config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the + * override surface. + */ +export const DEFAULT_HOOK_TIMEOUT_MS = 600_000 + +/** Everything a single hook invocation needs beyond its command line. */ +export interface RunHookOptions { + /** The JSON payload object written to the hook's stdin (the bridge builds it). */ + payload: unknown + /** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */ + env?: Record<string, string> + /** Working directory for the hook (defaults to the executor's own default when omitted). */ + cwd?: string + /** Abort signal — cancels the hook run when fired (the parent step aborts). */ + signal?: AbortSignal + /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */ + trailingNewline: boolean + /** + * Timeout applied when the hook's config sets no `timeout` of its own. The + * bridge owns the default (its `defaultTimeoutMs` config, reference default + * {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly. + */ + defaultTimeoutMs: number + /** + * The event this hook is firing for (e.g. `'PreToolUse'`). When set, a + * structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT + * event is treated as malformed and its event-scoped fields are discarded (see + * {@link parseHookOutput}). Omit it to apply any block as-is. + */ + expectedEventName?: string +} + +/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */ +export interface RunHookResult { + output: HookOutput + /** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */ + durationMs: number +} + +/** + * Run `hook` via `bash` with `options.payload` serialized to its stdin, then + * decode the result into a {@link HookOutput}. The hook's configured + * `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`. + * The command runs with the dialect's `env` merged after the executor's + * credential scrub (the trusted-plugin path). NEVER throws: an infrastructure + * failure (the executor rejecting) is surfaced as a {@link HookOutput} with + * `exitCode: undefined`, so the caller's merge logic treats it as a + * non-blocking error rather than crashing the turn. `now` is injected for + * testable durations. + */ +export async function runHook( + bash: BashExecutor, + hook: CommandHook, + options: RunHookOptions, + now: () => number, +): Promise<RunHookResult> { + const started = now() + const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs + const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '') + + const request = { + command: hook.command, + timeoutMs, + stdin, + ...options.cwd !== undefined ? { workdir: options.cwd } : {}, + ...options.env !== undefined ? { env: options.env } : {}, + ...options.signal ? { signal: options.signal } : {}, + } + + try { + const result = await bash.run(bash.resolve(request)) + // BashRunResult.exitCode is `number | null` (null = died by signal); the + // protocol's exit-code contract is numeric, so a signal death maps to + // `undefined` (a non-blocking error — no clean exit code to act on). + const exitCode = result.exitCode ?? undefined + return { + output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName), + durationMs: now() - started, + } + } catch (error: unknown) { + // The executor rejects only on infrastructure faults (unusable workdir, + // missing shell). A hook that cannot run is a non-blocking error: no exit + // code, the failure on stderr for the record. The turn proceeds. + const message = error instanceof Error ? error.message : String(error) + return { + output: parseHookOutput(undefined, '', message), + durationMs: now() - started, + } + } +} diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts new file mode 100644 index 0000000000..ef9fc7f9b9 --- /dev/null +++ b/packages/hooks/hook-protocol/src/types.ts @@ -0,0 +1,157 @@ +/** + * Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol, + * plus the log-only `hook/*` session events. Types only — runtime helpers live + * in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`). + * + * This package is the SHARED CORE: the truly-identical primitives both the + * `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns + * its own per-dialect stdin-payload construction and decision mapping on top of + * these primitives — the divergences (which events exist, literal-vs-regex + * matching, env/substitution, snake_case extras, allow/ask support) are the + * BRIDGE's concern, not this lib's. + * + * @module @deepseek-ai/dsh-hook-protocol/types + */ + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * A hook command was invoked at a hook point — log-only provenance (like + * `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`). + * `dialect` is the bridge that ran it (`claude`/`codex`), `point` + * the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group + * pattern that selected it (absent for match-all), `handlerId` a stable id + * for the command (so an invoked/result pair correlates). `turn` is the open + * turn the invocation lives inside. + */ + 'hook/invoked': { + turn: number + point: string + dialect: HookDialect + matcher?: string + handlerId: string + } + /** + * A hook command's outcome — log-only, paired with a prior `hook/invoked` + * (same `handlerId`). `decision` is the dialect-neutral outcome derived by + * `appendHookResult` (which owns the rule): the hook's parsed decision + * (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to + * halt via `continue:false`, else `'pass'`. `exitCode` is the process exit + * (absent if it never ran), `stderrSummary` the trimmed stderr truncated to + * the bridge's configured cap (the block reason source on exit 2), + * `durationMs` the wall-clock runtime (audit timing; snapshot replay + * normalizes it). `turn` matches the `hook/invoked`. + */ + 'hook/result': { + turn: number + point: string + handlerId: string + decision: string + exitCode?: number + stderrSummary?: string + durationMs: number + } + } +} + +/** + * The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex + * bridge `'codex'`. A native plugin on the interception seams is not a bridge + * and writes no `hook/*` provenance (see the interception-seams RFC). + */ +export type HookDialect = 'claude' | 'codex' + +/** + * One configured command hook (the `{ type: 'command', command, timeout? }` + * shape shared by both dialects). Non-command hook types (CC's `prompt`/`agent`/ + * `http`) are parsed-and-skipped by a bridge, so only this shape reaches the + * runner. + */ +export interface CommandHook { + /** The shell command line to run. */ + command: string + /** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */ + timeoutSec?: number +} + +/** + * One matcher group: a `matcher` pattern (absent / `''` / `'*'` = match-all) + * plus the command hooks that run when it matches. Both dialects share this + * shape (CC's `hooks.json` and Codex's `hooks.json`). + */ +export interface MatcherGroup { + matcher?: string + hooks: CommandHook[] +} + +/** + * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the + * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and + * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the + * mode for its dialect. + */ +export type MatcherMode = 'claude' | 'codex' + +/** + * The dialect-neutral OUTCOME a hook produced, parsed from its exit code + + * stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a + * seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field + * is OPTIONAL because a hook may exercise any subset; the bridge decides which + * fields are meaningful for its hook point and which it ignores (faithful-but- + * degraded — e.g. Codex ignores `allow`/`ask`). + */ +export interface HookOutput { + /** The raw process exit code (`undefined` if the hook could not be run). */ + exitCode: number | undefined + /** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */ + stderr: string + /** + * Trimmed stdout, verbatim. On a clean exit a hook may emit PLAIN (non-JSON) + * stdout that the protocol renders as output (CC) or treats as + * `additionalContext` (Codex SessionStart/UserPromptSubmit) — so the bridge + * needs the raw text, not just the parsed structured fields. Empty string when + * the hook produced no stdout. + */ + stdout: string + /** + * `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with + * {@link stopReason}. `true`/absent ⇒ proceed. + */ + continue?: boolean + /** Human-readable reason shown when {@link continue} is `false`. */ + stopReason?: string + /** + * The neutral blocking decision a hook expressed, folded from the two channels + * the reference protocols keep DISTINCT: the legacy top-level `decision` + * (`approve`/`block` only) and `hookSpecificOutput.permissionDecision` + * (`allow`/`deny`/`ask`). We normalize them to one enum — `'block'`/`'deny'` + * forbid, `'approve'`/`'allow'` permit, `'ask'` requests confirmation — but + * `'allow'`/`'deny'`/`'ask'` arise ONLY from a `permissionDecision`, never from + * a top-level `decision` (an out-of-band `{"decision":"deny"}` is invalid and + * ignored, matching the schemas). Absent ⇒ no explicit decision (exit code governs). + */ + decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask' + /** The reason/explanation accompanying {@link decision}. */ + reason?: string + /** + * The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted + * a `hookSpecificOutput` block. The reference schemas key that block by event, + * so a block whose `hookEventName` names a DIFFERENT event than the one firing + * is malformed: {@link parseHookOutput} DISCARDS its event-scoped fields when + * given the firing event's `expectedEventName` (a hook claiming `PreToolUse` + * output on a `Stop` event does not affect the `Stop`). This field is still + * surfaced even on a mismatch — the record shows what the block claimed. Absent + * when the hook emitted no `hookSpecificOutput`. + */ + hookEventName?: string + /** Extra context to inject for the next model request (CC `additionalContext`). */ + additionalContext?: string + /** A warning surfaced to the user (CC `systemMessage`). */ + systemMessage?: string + /** + * A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT + * honored — input rewrite is deferred (see the interception-seams RFC); a + * bridge logs + warns when this is present. + */ + updatedInput?: Record<string, unknown> +} diff --git a/packages/hooks/hook-protocol/tests/codec.spec.ts b/packages/hooks/hook-protocol/tests/codec.spec.ts new file mode 100644 index 0000000000..252caa4cff --- /dev/null +++ b/packages/hooks/hook-protocol/tests/codec.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol' + +describe('parseHookOutput — exit code semantics', () => { + it('exit 0 with no stdout is a neutral success', () => { + const out = parseHookOutput(0, '', '') + expect(out.exitCode).toBe(0) + expect(out.decision).toBeUndefined() + expect(out.continue).toBeUndefined() + }) + + it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => { + const out = parseHookOutput(2, '', 'this command is not allowed') + expect(out.decision).toBe('block') + expect(out.reason).toBe('this command is not allowed') + expect(out.stderr).toBe('this command is not allowed') + }) + + it('exit 2 with empty stderr still blocks, with no reason', () => { + const out = parseHookOutput(2, '', ' ') + expect(out.decision).toBe('block') + expect(out.reason).toBeUndefined() + }) + + it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => { + const out = parseHookOutput(1, '', 'some warning') + expect(out.decision).toBeUndefined() + expect(out.exitCode).toBe(1) + expect(out.stderr).toBe('some warning') + }) + + it('undefined exit (could not run) carries no decision', () => { + const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT') + expect(out.exitCode).toBeUndefined() + expect(out.decision).toBeUndefined() + expect(out.stderr).toBe('spawn failed: ENOENT') + }) +}) + +describe('parseHookOutput — structured stdout (exit 0 only)', () => { + it('parses top-level continue/stopReason/systemMessage', () => { + const out = parseHookOutput(0, JSON.stringify({ + continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up', + }), '') + expect(out.continue).toBe(false) + expect(out.stopReason).toBe('budget exceeded') + expect(out.systemMessage).toBe('heads up') + }) + + it('parses legacy top-level decision + reason (approve/block ONLY)', () => { + expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block') + expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve') + }) + + it('a top-level decision of allow/deny/ask is INVALID and ignored (reserved for permissionDecision)', () => { + // Both reference schemas restrict the legacy top-level `decision` to + // approve/block; allow/deny/ask must come from hookSpecificOutput.permissionDecision. + expect(parseHookOutput(0, JSON.stringify({ decision: 'deny' }), '').decision).toBeUndefined() + expect(parseHookOutput(0, JSON.stringify({ decision: 'allow' }), '').decision).toBeUndefined() + expect(parseHookOutput(0, JSON.stringify({ decision: 'ask' }), '').decision).toBeUndefined() + }) + + it('captures hookEventName from hookSpecificOutput (the discriminator a bridge validates)', () => { + const out = parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), '') + expect(out.hookEventName).toBe('PreToolUse') + expect(out.decision).toBe('deny') + }) + + it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => { + const out = parseHookOutput(0, JSON.stringify({ + decision: 'approve', + hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' }, + }), '') + expect(out.decision).toBe('deny') + expect(out.reason).toBe('denied by policy') + }) + + it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => { + expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow') + expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask') + }) + + it('parses additionalContext and updatedInput from hookSpecificOutput', () => { + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } }, + }), '') + expect(out.additionalContext).toBe('remember X') + expect(out.updatedInput).toEqual({ command: 'safe' }) + }) + + it('an unknown decision string is ignored (not coerced)', () => { + expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined() + }) + + it('DISCARDS a hookSpecificOutput block whose hookEventName mismatches the firing event', () => { + // A PreToolUse block emitted on a Stop hook is malformed — its event-scoped + // fields must not take effect (a stray PreToolUse deny must not deny the Stop). + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'no', additionalContext: 'x', updatedInput: { command: 'y' } }, + }), '', 'Stop') + expect(out.hookEventName).toBe('PreToolUse') // still recorded for the log + expect(out.decision).toBeUndefined() // event-scoped fields discarded + expect(out.reason).toBeUndefined() + expect(out.additionalContext).toBeUndefined() + expect(out.updatedInput).toBeUndefined() + }) + + it('APPLIES a hookSpecificOutput block whose hookEventName matches the firing event', () => { + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', additionalContext: 'x' }, + }), '', 'PreToolUse') + expect(out.decision).toBe('deny') + expect(out.additionalContext).toBe('x') + }) + + it('applies the block when expectedEventName is omitted (opt-out) even if it names an event', () => { + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, + }), '') + expect(out.decision).toBe('deny') + }) + + it('DISCARDS a block with NO hookEventName when a firing event is expected', () => { + // Under the keyed schema a missing discriminator is as malformed as a + // mismatched one: a discriminator-less block must not apply its event-scoped + // permission fields to whatever event happens to be firing. + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { permissionDecision: 'deny', additionalContext: 'x' }, + }), '', 'Stop') + expect(out.hookEventName).toBeUndefined() // none to record + expect(out.decision).toBeUndefined() // event-scoped fields discarded + expect(out.additionalContext).toBeUndefined() + }) + + it('applies a discriminator-less block when expectedEventName is omitted (opt-out)', () => { + // With no firing event to validate against, the block applies as-is. + const out = parseHookOutput(0, JSON.stringify({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }), '') + expect(out.decision).toBe('deny') + }) + + it('a mismatched block does NOT discard the event-agnostic top-level decision/continue', () => { + // Only the per-event block is scoped; top-level fields are event-agnostic. + const out = parseHookOutput(0, JSON.stringify({ + decision: 'block', reason: 'top', continue: false, stopReason: 'halt', + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' }, + }), '', 'Stop') + expect(out.decision).toBe('block') // top-level survives; the allow block was discarded + expect(out.reason).toBe('top') + expect(out.continue).toBe(false) + expect(out.stopReason).toBe('halt') + }) + + it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => { + const out = parseHookOutput(0, '{ not valid json', '') + expect(out.decision).toBeUndefined() + expect(out.continue).toBeUndefined() + }) + + it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => { + const out = parseHookOutput(0, 'just some text output', '') + expect(out.decision).toBeUndefined() + expect(out.continue).toBeUndefined() + // The raw stdout is preserved verbatim so the bridge can render/use it + // (CC output; Codex additionalContext) — trimmed. + expect(out.stdout).toBe('just some text output') + }) + + it('preserves raw stdout (trimmed) alongside parsed structured fields', () => { + const json = JSON.stringify({ decision: 'block' }) + const out = parseHookOutput(0, ` ${json} \n`, '') + expect(out.stdout).toBe(json) + expect(out.decision).toBe('block') + }) + + it('stdout is empty string when the hook emits none', () => { + expect(parseHookOutput(0, '', '').stdout).toBe('') + }) + + it('a JSON array stdout parses but yields no fields (not an object)', () => { + // Starts with '{'? No — '[' — so it is not even attempted. Neutral. + const out = parseHookOutput(0, '[1,2,3]', '') + expect(out.decision).toBeUndefined() + }) + + it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => { + const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked') + // exit 2 forces block regardless of what stdout claims + expect(out.decision).toBe('block') + expect(out.reason).toBe('blocked') + }) +}) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts new file mode 100644 index 0000000000..f978da2645 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol' + +/** A {@link HookOutput} with the required stream fields defaulted. */ +function output(over: Partial<HookOutput> = {}): HookOutput { + return { exitCode: 0, stderr: '', stdout: '', ...over } +} + +describe('hook/* session events', () => { + it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => { + const session = new Session(SessionId('s')) + appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' }) + + const ev = [...session.events].find(e => e.type === 'hook/invoked') + expect(ev?.type).toBe('hook/invoked') + if (ev?.type === 'hook/invoked') { + expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' }) + } + // Log-only: no surfaceOp on the event. + expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined() + }) + + it('omits matcher when absent (match-all hook)', () => { + const session = new Session(SessionId('s')) + appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) + + const ev = [...session.events].find(e => e.type === 'hook/invoked') + if (ev?.type === 'hook/invoked') { + expect('matcher' in ev.data).toBe(false) + } + }) + + it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { + turn: 1, point: 'PreToolUse', handlerId: 'h1', + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), + }) + const full = [...session.events].find(e => e.type === 'hook/result') + if (full?.type === 'hook/result') { + expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) + } + + // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys. + const session2 = new Session(SessionId('s2')) + appendHookResult(session2, { + turn: 1, point: 'Stop', handlerId: 'h3', + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), + }) + const sparse = [...session2.events].find(e => e.type === 'hook/result') + if (sparse?.type === 'hook/result') { + expect('exitCode' in sparse.data).toBe(false) + expect('stderrSummary' in sparse.data).toBe(false) + expect(sparse.data.decision).toBe('allow') + } + }) + + it('the decision falls back to stop on continue:false, else pass', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) }) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() }) + // An explicit decision wins over the continue:false fallback. + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) + + const decisions = [...session.events] + .filter(e => e.type === 'hook/result') + .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) + expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) + }) + + it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { + turn: 1, point: 'PreToolUse', handlerId: 'long', + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), + }) + const ev = [...session.events].find(e => e.type === 'hook/result') + if (ev?.type === 'hook/result') { + expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') + } + }) + + it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { + turn: 1, point: 'PreToolUse', handlerId: 'edge', + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), + }) + const ev = [...session.events].find(e => e.type === 'hook/result') + if (ev?.type === 'hook/result') { + expect(ev.data.stderrSummary).toBe('y'.repeat(500)) + } + }) + + it('an invoked/result pair correlates by handlerId', () => { + const session = new Session(SessionId('s')) + appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' }) + appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) + + const invoked = [...session.events].find(e => e.type === 'hook/invoked') + const result = [...session.events].find(e => e.type === 'hook/result') + expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') + expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') + }) +}) + +describe('summarizeStderr', () => { + it('returns undefined for empty/whitespace stderr', () => { + expect(summarizeStderr('', 500)).toBeUndefined() + expect(summarizeStderr(' \n\t ', 500)).toBeUndefined() + }) + + it('passes through a summary at or under the cap, trimmed', () => { + expect(summarizeStderr(' blocked: bad tool ', 500)).toBe('blocked: bad tool') + expect(summarizeStderr('abc', 3)).toBe('abc') + }) + + it('truncates past the cap with an ellipsis', () => { + expect(summarizeStderr('abcdef', 4)).toBe('abcd…') + expect(summarizeStderr('x'.repeat(600), 500)).toBe('x'.repeat(500) + '…') + }) +}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts new file mode 100644 index 0000000000..37e2acb137 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' + +describe('matchesMatcher — match-all sentinels (both dialects)', () => { + for (const mode of ['claude', 'codex'] as const) { + it(`${mode}: absent / empty / '*' match everything`, () => { + expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true) + expect(matchesMatcher('', 'anything', mode)).toBe(true) + expect(matchesMatcher('*', 'whatever', mode)).toBe(true) + }) + } +}) + +describe('matchesMatcher — claude dialect (literal-or-regex)', () => { + it('a pure word-char pattern is a LITERAL exact match (not substring)', () => { + expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true) + // literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring) + expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false) + }) + + it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => { + expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true) + expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true) + expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false) + // still exact per-alternative, not substring + expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false) + }) + + it('a non-word pattern falls through to regex (unanchored)', () => { + expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true) + expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true) + expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true) + expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false) + }) +}) + +describe('matchesMatcher — codex dialect (always regex)', () => { + it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { + expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) + // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) + }) + + it('regex alternation and anchors work', () => { + expect(matchesMatcher('Edit|Write', 'Edit', 'codex')).toBe(true) + expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) + expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) + }) +}) + +describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { + it('an unbalanced pattern matches nothing rather than throwing', () => { + // '(' is not the claude-literal charset, so it goes to the regex path and is invalid. + expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow() + expect(matchesMatcher('(', 'x', 'claude')).toBe(false) + expect(matchesMatcher('[', 'x', 'codex')).toBe(false) + }) +}) diff --git a/packages/hooks/hook-protocol/tests/merge.spec.ts b/packages/hooks/hook-protocol/tests/merge.spec.ts new file mode 100644 index 0000000000..def2474927 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/merge.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol' +import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol' + +function out(over: Partial<HookOutput> = {}): HookOutput { + return { exitCode: 0, stderr: '', stdout: '', ...over } +} + +describe('mergeHookOutputs — permission precedence deny > ask > allow', () => { + it('empty list yields a neutral outcome', () => { + const m = mergeHookOutputs([]) + expect(m.decision).toBe('none') + expect(m.stop).toBe(false) + expect(m.additionalContext).toEqual([]) + expect(m.systemMessages).toEqual([]) + }) + + it('a single allow yields allow', () => { + expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow') + expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow') + }) + + it('deny beats ask beats allow regardless of order', () => { + expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask') + expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny') + expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny') + // block folds to deny + expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny') + }) + + it('no decision anywhere yields none', () => { + expect(mergeHookOutputs([out(), out()]).decision).toBe('none') + }) +}) + +describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => { + it('joins block/deny reasons with a blank line (only from blocking hooks)', () => { + const m = mergeHookOutputs([ + out({ decision: 'deny', reason: 'first objection' }), + out({ decision: 'allow', reason: 'this allow reason is NOT collected' }), + out({ decision: 'block', reason: 'second objection' }), + ]) + expect(m.reason).toBe('first objection\n\nsecond objection') + }) + + it('no reason when nothing blocked', () => { + expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined() + }) + + it('surfaces the reason of the WINNING decision: an ask-winning outcome shows the ask reason', () => { + const m = mergeHookOutputs([ + out({ decision: 'allow', reason: 'allow reason — not surfaced' }), + out({ decision: 'ask', reason: 'needs approval' }), + ]) + expect(m.decision).toBe('ask') + expect(m.reason).toBe('needs approval') + }) + + it('when deny wins over ask, the ask reasons are dropped (only the winning rank\'s reasons)', () => { + const m = mergeHookOutputs([ + out({ decision: 'ask', reason: 'ask reason — not surfaced once deny wins' }), + out({ decision: 'deny', reason: 'the real objection' }), + ]) + expect(m.decision).toBe('deny') + expect(m.reason).toBe('the real objection') + }) + + it('stop is sticky on the first continue:false, capturing its stopReason', () => { + const m = mergeHookOutputs([ + out({ continue: true }), + out({ continue: false, stopReason: 'halt now' }), + out({ continue: false, stopReason: 'second halt — ignored' }), + ]) + expect(m.stop).toBe(true) + expect(m.stopReason).toBe('halt now') + }) + + it('no stop when every hook continues', () => { + const m = mergeHookOutputs([out({ continue: true }), out()]) + expect(m.stop).toBe(false) + expect(m.stopReason).toBeUndefined() + }) + + it('a continue:false with no stopReason stops with an undefined reason', () => { + const m = mergeHookOutputs([out({ continue: false })]) + expect(m.stop).toBe(true) + expect(m.stopReason).toBeUndefined() + }) + + it('collects additionalContext and systemMessages in hook order, skipping empties', () => { + const m = mergeHookOutputs([ + out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }), + out({ additionalContext: '', systemMessage: '' }), // empties skipped + out({ additionalContext: 'ctx-B' }), + out({ systemMessage: 'warn-B' }), + ]) + expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B']) + expect(m.systemMessages).toEqual(['warn-A', 'warn-B']) + }) +}) diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts new file mode 100644 index 0000000000..1972a39c99 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' +import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol' + +/** + * A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook} + * actually calls (`resolve` then `run`). `runHook` is pure plumbing over those + * two methods, so a duck-typed recorder is the right test seam — the REAL + * executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins + * that consume this library, not here. + */ +function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): { + bash: BashExecutor + specs: BashExecSpec[] +} { + const specs: BashExecSpec[] = [] + const bash = { + resolve(request: BashExecRequest): BashExecSpec { + // Carry the request through verbatim, defaulting the required spec fields — + // exactly what dsh-bash-local's resolve does for the fields runHook sets. + return { + command: request.command, + workdir: request.workdir ?? '/stub', + timeoutMs: request.timeoutMs ?? 0, + ...request.signal ? { signal: request.signal } : {}, + ...request.stdin !== undefined ? { stdin: request.stdin } : {}, + ...request.env !== undefined ? { env: request.env } : {}, + owner: request.owner, + } + }, + async run(spec: BashExecSpec): Promise<BashRunResult> { + specs.push(spec) + return run(spec) + }, + } as unknown as BashExecutor + return { bash, specs } +} + +function result(over: Partial<BashRunResult> = {}): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 1000, + stdout: { text: '', truncated: false }, + stderr: { text: '', truncated: false }, + ...over, + } +} + +const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5 + +describe('runHook — payload + env + stdin plumbing', () => { + it('serializes the payload to stdin (with trailing newline when requested)', async () => { + const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } })) + await runHook(bash, { command: 'my-hook.sh' }, { + payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' }, + defaultTimeoutMs: 60000, + trailingNewline: true, + }, clock()) + expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n') + expect(specs[0]!.command).toBe('my-hook.sh') + }) + + it('omits the trailing newline when trailingNewline is false (Codex)', async () => { + const { bash, specs } = recordingBash(async () => result()) + await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock()) + expect(specs[0]!.stdin).toBe('{"a":1}') + }) + + it('threads env and cwd into the request', async () => { + const { bash, specs } = recordingBash(async () => result()) + await runHook(bash, { command: 'h' }, { + payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', + defaultTimeoutMs: 1000, trailingNewline: true, + }, clock()) + expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' }) + expect(specs[0]!.workdir).toBe('/work') + }) + + it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => { + const { bash, specs } = recordingBash(async () => result()) + await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + expect(specs[0]!.timeoutMs).toBe(3000) + }) + + it('falls back to the default timeout when the hook sets none', async () => { + const { bash, specs } = recordingBash(async () => result()) + await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + expect(specs[0]!.timeoutMs).toBe(60000) + expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes) + }) + + it('passes the abort signal through', async () => { + const controller = new AbortController() + const { bash, specs } = recordingBash(async () => result()) + await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + expect(specs[0]!.signal).toBe(controller.signal) + }) +}) + +describe('runHook — outcome decoding + duration', () => { + it('decodes a clean exit with structured stdout and reports a duration', async () => { + const { bash } = recordingBash(async () => result({ + exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false }, + })) + const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + expect(output.decision).toBe('block') + expect(output.reason).toBe('no') + expect(durationMs).toBe(5) + }) + + it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => { + const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } })) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + expect(output.exitCode).toBeUndefined() + expect(output.decision).toBeUndefined() + expect(output.stderr).toBe('killed') + }) + + it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => { + const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') }) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + expect(output.exitCode).toBeUndefined() + expect(output.stderr).toBe('bad workdir: ENOENT') + expect(output.decision).toBeUndefined() + }) + + it('a non-Error rejection is stringified onto stderr', async () => { + const { bash } = recordingBash(async () => { throw 'plain string fault' }) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + expect(output.stderr).toBe('plain string fault') + }) + + it('threads expectedEventName so a mismatched hookSpecificOutput block is discarded', async () => { + const { bash } = recordingBash(async () => result({ + exitCode: 0, + stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false }, + })) + const { output } = await runHook(bash, { command: 'h' }, { + payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', + }, clock()) + // A PreToolUse block on a Stop hook is malformed → its decision is discarded. + expect(output.hookEventName).toBe('PreToolUse') + expect(output.decision).toBeUndefined() + }) +}) diff --git a/packages/hooks/hook-protocol/tsconfig.json b/packages/hooks/hook-protocol/tsconfig.json new file mode 100644 index 0000000000..dc4f8d9e16 --- /dev/null +++ b/packages/hooks/hook-protocol/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md new file mode 100644 index 0000000000..554fb4b849 --- /dev/null +++ b/packages/hooks/hooks-claude/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-hooks-claude + +A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns CC's per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md). + +A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). + +## Config + +```ts +import type { Config } from '@deepseek-ai/dsh-hooks-claude' +const config: Config = { + configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key + pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings + projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted + defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default) + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary +} +``` + +In a `cordis.yml`: + +```yaml +- dsh-hooks-claude: + configPath: ./.claude/hooks.json + pluginRoot: ./.claude/plugins/my-plugin + projectDir: . +``` + +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). + +The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. + +## Hook points → seam Decisions + +| CC hook | Harness seam | Mapping | +|---|---|---| +| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) | +| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) | +| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | +| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering | +| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child | +| `SubagentStop` | `subagent/end` (emit) | observe-only | + +The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note). + +## Context source + +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. + +## Deferred (faithful-but-degraded) + +- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). +- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it. +- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json new file mode 100644 index 0000000000..5cc39f9999 --- /dev/null +++ b/packages/hooks/hooks-claude/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-hooks-claude", + "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts new file mode 100644 index 0000000000..d78486e58a --- /dev/null +++ b/packages/hooks/hooks-claude/src/config.ts @@ -0,0 +1,100 @@ +/** + * Parse a Claude Code hook config file into the shared {@link MatcherGroup} + * shape, faithfully to CC's `hooks.json` / settings `hooks` key format. + * + * A CC config maps each event name to an array of matcher groups, each holding + * an array of typed hooks. Only `type: 'command'` hooks run here; other types + * (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but- + * degraded — the same stance Codex takes). The `command` string undergoes + * `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal. + * + * @module @deepseek-ai/dsh-hooks-claude/config + */ + +import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' + +/** A parsed CC config: event name → its matcher groups (command hooks only). */ +export type ClaudeHookConfig = Record<string, MatcherGroup[]> + +/** A skipped non-command hook, surfaced so the bridge can warn about it. */ +export interface SkippedHook { + event: string + type: string +} + +/** The outcome of parsing one config file: the runnable groups + what was skipped. */ +export interface ParsedClaudeConfig { + config: ClaudeHookConfig + skipped: SkippedHook[] +} + +/** Substitution variables applied to each `command` string at parse time. */ +export interface SubstitutionVars { + /** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */ + pluginRoot?: string + /** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */ + projectDir?: string +} + +/** A plain (non-null, non-array) object, else undefined. */ +function asObject(value: unknown): Record<string, unknown> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : undefined +} + +/** Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. */ +export function substituteCommand(command: string, vars: SubstitutionVars): string { + let out = command + if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot) + if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir) + return out +} + +/** + * Parse a raw Claude Code config object (the value under the `hooks` key, or a + * `hooks.json` whose top level IS that map) into runnable {@link MatcherGroup}s. + * Non-command hooks and malformed entries are dropped (recorded in `skipped` / + * silently ignored) rather than throwing — a bad hook config must not crash boot. + * `vars` are substituted into every surviving `command`. + */ +export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig { + const config: ClaudeHookConfig = {} + const skipped: SkippedHook[] = [] + // Accept either `{ hooks: { … } }` (a settings file) or the bare event map. + const root = asObject(raw) + const hooksMap = root ? asObject(root.hooks) ?? root : undefined + if (!hooksMap) return { config, skipped } + + for (const [event, rawGroups] of Object.entries(hooksMap)) { + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { + skipped.push({ event, type }) + continue + } + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, + }) + } + if (commands.length === 0) continue + groups.push({ + ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, + hooks: commands, + }) + } + if (groups.length > 0) config[event] = groups + } + + return { config, skipped } +} diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts new file mode 100644 index 0000000000..4ca9ee001e --- /dev/null +++ b/packages/hooks/hooks-claude/src/index.ts @@ -0,0 +1,414 @@ +/** + * `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code + * hook config (`hooks.json` / a settings file's `hooks` key) on the harness's + * canonical interception seams. It is the CC DIALECT half of the hooks + * subsystem: it owns CC's per-event stdin payloads, CC's env + + * `${CLAUDE_PLUGIN_ROOT}` substitution, and the mapping from a hook's neutral + * outcome onto the harness's typed Decisions. The dialect-agnostic primitives + * (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive + * merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`. + * + * A native cordis plugin could do everything this bridge does — more powerfully, + * with typed returns and no serialization boundary. The bridge exists only to + * run UNMODIFIED external CC hooks faithfully; anything bespoke should be a + * native plugin on the same seams. + * + * Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`, + * `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only + * `type: 'command'` hooks run; the matcher group config + exit-code/stdout + * protocol are byte-faithful to CC. `updatedInput` (tool-input rewrite) is + * logged + warned, not honored (deferred — see the interception-seams RFC). + * + * @module @deepseek-ai/dsh-hooks-claude + */ + +import { readFileSync } from 'node:fs' +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { + appendHookInvoked, + appendHookResult, + DEFAULT_HOOK_TIMEOUT_MS, + DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, + mergeHookOutputs, + runHook, + type HookOutput, + type MatcherGroup, + type MergedHookOutcome, +} from '@deepseek-ai/dsh-hook-protocol' +// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event +// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the +// SubagentStart/SubagentStop listeners below type-check. +import type {} from '@deepseek-ai/dsh-subagent' +import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' + +export const name = 'hooks-claude' +// `bash` is required to run hooks; the rest are read opportunistically via +// ctx.get so a deployment can load this bridge without every seam present. +export const inject = ['bash'] + +/** Plugin config: where the CC hook config lives + substitution roots. */ +export interface Config { + /** + * Path to a `hooks.json` or a settings file whose `hooks` key holds the config. + * PROCESS-LEVEL: read once at load, a relative path resolves against the process + * launch cwd, so one config applies to the whole process. + * TODO(per-session-hook-config): per-session discovery of a project-local + * `hooks.json` from each `session/new.cwd` is not yet implemented. + */ + configPath: string + /** + * Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir). + */ + pluginRoot?: string + /** + * Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the + * `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var + * defaults per-run to the agent's session workspace (`session.header.cwd`, the + * same dir the hook runs in) — Claude Code always exports this var, and common + * unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths. + */ + projectDir?: string + /** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */ + defaultTimeoutMs?: number + /** Character cap for the `hook/result` event's persisted stderr summary. */ + stderrSummaryMaxChars?: number +} + +export const Config: z<Config> = z.object({ + configPath: z.string().required(), + pluginRoot: z.string(), + projectDir: z.string(), + defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS), + stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS), +}) + +/** A stable per-handler id so an invoked/result pair correlates in the log. */ +let handlerCounter = 0 +function nextHandlerId(point: string): string { + return `claude:${point}:${++handlerCounter}` +} + +/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */ +const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' } + +/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`hooks-claude: ${name} must be a positive integer`) + } +} + +export function apply(ctx: Context, config: Config): void { + // Validate the cap BEFORE the config-file parse: a bad value must fail the + // load loudly, not be skipped by the parse-failure early return. + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS + assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) + const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS + // --- Parse the config ONCE at load. A read/parse failure is contained: the + // bridge logs and registers nothing rather than crashing boot (a typo'd path + // must not take the agent down). --- + let parsed: ClaudeHookConfig = {} + try { + const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) + const result = parseClaudeConfig(raw, { + ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {}, + ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {}, + }) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) + } + } catch (error: unknown) { + ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) + return + } + + /** + * Run every command hook configured for `point` whose matcher selects + * `matchQuery`, with the per-event `payload` on stdin, and fold the results. + * Writes a `hook/invoked`/`hook/result` pair per hook into the session when one + * is available (the mid-turn points always have an open turn). Returns the + * merged outcome (a neutral, already-most-restrictive view) for the caller to + * map onto its seam decision. `matchQuery` is the event's matcher subject + * (tool name, session source, …); `''` for events that ignore matchers. + */ + async function runPoint( + point: string, + matchQuery: string, + payload: unknown, + opts: { agent?: Agent; turn?: number; signal?: AbortSignal }, + ): Promise<MergedHookOutcome> { + const groups: MatcherGroup[] = parsed[point] ?? [] + const outputs: HookOutput[] = [] + // Run the hook in the AGENT'S session workspace (the `session/new` cwd on the + // session header), not the executor default (the ACP server's launch dir). + // A hook that does `pwd`, reads a relative file, or writes a marker must + // operate in the user's project tree. Absent for a no-agent run (falls back + // to the executor default). + const workdir = opts.agent?.session.header.cwd + // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to + // the session workspace (the same dir the hook RUNS in). Claude Code always + // exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR` + // (shell expansion at run time) for project-relative paths — leaving it empty + // in the default ACP wiring (no `projectDir` configured) would break them even + // though the bridge already knows the workspace. Absent only for a no-agent run + // with no configured projectDir (nothing to point at). + const projectDir = config.projectDir ?? workdir + const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined + for (const group of groups) { + if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue + for (const hook of group.hooks) { + const handlerId = nextHandlerId(point) + const session = opts.agent?.session + if (session && opts.turn !== undefined) { + appendHookInvoked(session, { + turn: opts.turn, point, dialect: 'claude', handlerId, + ...group.matcher !== undefined ? { matcher: group.matcher } : {}, + }) + } + const { output, durationMs } = await runHook(ctx.bash, hook, { + payload, + defaultTimeoutMs, + ...hookEnv ? { env: hookEnv } : {}, + ...workdir !== undefined ? { cwd: workdir } : {}, + ...opts.signal ? { signal: opts.signal } : {}, + trailingNewline: true, + // Discard a `hookSpecificOutput` block whose `hookEventName` names a + // different event than the one firing (the schemas key it by event). + expectedEventName: point, + }, () => performance.now()) + outputs.push(output) + if (output.updatedInput !== undefined) { + ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`) + } + if (output.systemMessage !== undefined) { + ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) + } + if (session && opts.turn !== undefined) { + appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs }) + } + } + } + return mergeHookOutputs(outputs) + } + + // TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from + // a hook's `continue:false`, but no seam below honors it — there is no + // "hard-halt the whole agent" primitive on the interception seams yet (a + // Decision can block/deny/steer a single point, not stop the run). Honoring it + // needs that primitive; deferred with the loop-guard work. Until then a + // `continue:false` hook still has its per-point effect (its decision/context), + // and the halt request is recorded in the `hook/result` log but not acted on. + + /** Build a HookContext from accumulated additionalContext strings, or undefined when none. */ + function contextFrom(merged: MergedHookOutcome): HookContext | undefined { + if (merged.additionalContext.length === 0) return undefined + const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text })) + return { content, source: PLUGIN_SOURCE } + } + + /** + * Concatenate this bridge's {@link HookContext} (`ours`, always present at the + * call sites) with a downstream listener's optional one, so folding our + * additionalContext onto a delegated decision drops neither. The merged block + * carries a single `source` — this bridge's — because a `HookContext` holds one + * `MessageSource` and the seam cannot represent mixed provenance; the rendered + * `context/message` only distinguishes by `source.kind` ('plugin'), so a + * downstream plugin's text is still correctly framed as plugin context, not a + * user prompt. + */ + function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { + if (!theirs) return ours + return { content: [...ours.content, ...theirs.content], source: ours.source } + } + + // --- SessionStart: emit (cannot block). Inject any additionalContext into the + // agent. The matcher subject is the source. + // TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and + // this hook runs on a detached `.then`, so the injected context is BEST-EFFORT + // — it is not guaranteed to land before the first turn reaches the model. A + // slow hook can miss the first request (the context then arrives as a later + // injection turn). Gating startup on the hook is a loop-level change deferred + // to the interception seams; today the contract is "injected as soon as the + // hook resolves", not "before the first request". --- + ctx.on('agent/session-start', (agent, source) => { + void runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent }) + .then((merged) => { + const context = contextFrom(merged) + if (context) agent.inject(context.content, { source: context.source }) + }) + .catch((error: unknown) => { + ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`) + }) + }) + + // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no + // matcher subject (CC ignores matchers for this event). --- + ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => { + const turn = lastTurn(agent) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn }) + if (merged.decision === 'deny') { + return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } + } + // Our hooks did not block. DELEGATE (attaching context alone is not a veto): + // a later `agent/prompt-submit` listener must still get to block or rewrite. + // Then fold our additionalContext onto its decision — a downstream block wins + // (a dropped prompt makes the context moot; `block` carries no context field). + const downstream = await next() + const ours = contextFrom(merged) + if (!ours || downstream.kind !== 'allow') return downstream + return { + kind: 'allow', + ...downstream.content !== undefined ? { content: downstream.content } : {}, + additionalContext: concatContext(ours, downstream.additionalContext), + } + }) + + // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- + ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { + const turn = lastTurn(exec.agent) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } + if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } + return next() + }) + + // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- + ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => { + const turn = lastTurn(exec.agent) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const context = contextFrom(merged) + if (merged.decision === 'deny') { + return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} } + } + // Our hooks did not block. DELEGATE so a later listener can still block/replace, + // then fold our context onto its decision (a downstream block carries it too). + const downstream = await next() + if (!context) return downstream + if (downstream.kind === 'block') { + return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) } + } + return { + kind: 'accept', + ...downstream.content !== undefined ? { content: downstream.content } : {}, + additionalContext: concatContext(context, downstream.additionalContext), + } + }) + + // --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to + // CONTINUE (block the stop) with stderr/reason as the continuation. No matcher. + // TODO(stop-loop-guard): CC breaks an infinite force-continue with + // `stop_hook_active` (set true once a Stop hook has already fired this run) plus + // a max-consecutive cap; both are deferred. Today `stop_hook_active` is always + // false, so a Stop hook that unconditionally blocks would force-continue every + // step — a hook author must self-limit until the guard lands. --- + ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => { + const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) + if (merged.decision === 'deny') { + // A blocking Stop hook forces continuation. It carries its reason as + // next-step steering; a blocking hook that emitted no reason (exit 2, empty + // stderr) still forces the turn to continue — the block is what matters, so + // fall back to a generic steering line rather than letting the turn stop. + const text = merged.reason ?? 'continue: blocked by Stop hook' + return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } } + } + return next() + }) + + // --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is + // observe-only this cut). A SubagentStart hook's additionalContext is injected + // into the live child; SubagentStop only observes. Both look the live child up + // so the hook runs in the child's session workspace and the payload carries + // the child's session_id/cwd (see subagentPayload). The matcher subject is the + // CC-default `agent_type` (SUBAGENT_TYPE) — the harness seam carries no + // per-kind label, so a config's default/`*`/empty agent_type matcher fires and + // a specific-kind matcher does not (documented in the RFC). --- + ctx.on('subagent/start', (info) => { + const child = ctx.get('agents')?.get(info.id) + void runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {} }) + .then((merged) => { + const context = contextFrom(merged) + if (context && child) child.inject(context.content, { source: context.source }) + }) + .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }) + }) + ctx.on('subagent/end', (info) => { + // Look up the child (still recoverable: `subagent/end` fires from the + // service's detached `.then` BEFORE the tool caller's `await run.result` + // disposes it) so the hook runs in the child's cwd, not the server default. + // No `.then`/inject follows (SubagentStop only observes), and no `turn` is + // passed (so no `hook/*` log records), so runPoint has nothing that can + // reject — no `.catch` is needed. Fire-and-forget. + const child = ctx.get('agents')?.get(info.id) + void runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {} }) + }) +} + +/** + * The `agent_type` value the bridge reports for SubagentStart/Stop. The harness + * subagent seam carries no per-kind label, so the bridge uses Claude Code's own + * Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher + * fires; a config matching a specific kind (e.g. `code-reviewer`) does not. + */ +const SUBAGENT_TYPE = 'general-purpose' + +// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's +// hook input schema; this is the part a bridge owns. --- + +/** The last (open or just-closed) turn number in the agent's log, or 0. */ +function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + const last = [...agent.session.events].findLast(e => e.type === 'turn/start') + /* v8 ignore next -- the `: 0` arm is a defensive fallback: lastTurn is only + called from the mid-turn seams (prompt-submit/pre-/post-execute/continuation), + which always run inside an open turn, so `last` is always a turn/start here. */ + return last?.type === 'turn/start' ? last.data.turn : 0 +} + +/** Flatten content blocks to the text a hook payload carries (the common case). */ +function blocksToText(content: ContentBlock[]): string { + return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('') +} + +function base(agent: Agent | undefined, event: string): Record<string, unknown> { + return { + session_id: agent?.session.header.id ?? '', + cwd: agent?.session.header.cwd ?? process.cwd(), + hook_event_name: event, + } +} + +function sessionStartPayload(agent: Agent, source: string): Record<string, unknown> { + return { ...base(agent, 'SessionStart'), source } +} +function promptPayload(agent: Agent, content: ContentBlock[]): Record<string, unknown> { + return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) } +} +function preToolPayload(exec: ToolExecution): Record<string, unknown> { + return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } +} +function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> { + return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +} +function stopPayload(agent: Agent): Record<string, unknown> { + return { ...base(agent, 'Stop'), stop_hook_active: false } +} +/** + * Build a SubagentStart/SubagentStop payload from the CC base (the child's + * `session_id`/`cwd` when the child agent is available) plus the subagent-hook + * fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active` + * is present on SubagentStop only (the loop-guard flag, always false this cut). + */ +function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> { + return { + ...base(child, event), + agent_id: info.id, + agent_type: SUBAGENT_TYPE, + ...event === 'SubagentStop' ? { stop_hook_active: false } : {}, + } +} diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts new file mode 100644 index 0000000000..3e36231e66 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** + * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL + * bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook + * scripts written to a temp dir — only the model is mocked (the "prefer the real + * implementation" rule). Each test writes a `hooks.json` + executable scripts, + * loads the bridge pointed at them, and asserts the hook's effect on the loop. + */ + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +/** Write a hooks.json + named executable scripts into a fresh temp dir. */ +function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks })) + for (const [name, body] of Object.entries(scripts)) { + const path = join(dir, name) + writeFileSync(path, body) + chmodSync(path, 0o755) + } + return dir +} + +async function harness(configDir: string, adapter: MockAdapter): Promise<Context> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +function events(agent: ReactLoopAgent): SessionEvent[] { + return [...agent.session.events] +} + +/** + * Poll `predicate` until it returns true or the deadline passes. Detached + * emit-listener hooks (session-start, subagent) fire on a `.then` the test can't + * await directly; polling for the observable EFFECT is robust under load, where a + * single fixed sleep flakes ("async state is not synchronous state"). + */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +describe('hooks-claude bridge — UserPromptSubmit', () => { + it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => { + // The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr. + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const block = join(dir, 'block.sh') + writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n') + chmodSync(block, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } })) + + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'do something' }]) + await waitForIdle(ctx, agent) + + // The prompt was blocked: model never called, turn ended rejected. + expect(adapter.requests).toHaveLength(0) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected') + // The hook ran and was recorded. + expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true) + expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true) + }) + + it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const ctxScript = join(dir, 'ctx.sh') + writeFileSync(ctxScript, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remember: be brief"}}\'\n') + chmodSync(ctxScript, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: ctxScript }] }] } })) + + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // The injected context reached the model and is recorded with the plugin source. + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief') + const ctxMsg = events(agent).find(e => e.type === 'context/message') + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) + }) +}) + +describe('hooks-claude bridge — PreToolUse', () => { + it('a matching PreToolUse hook that exits 2 denies the tool (isError result), tool never runs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const deny = join(dir, 'deny.sh') + writeFileSync(deny, '#!/usr/bin/env bash\necho "danger tool blocked" >&2\nexit 2\n') + chmodSync(deny, 0o755) + // Matcher "danger" (literal) selects only the danger tool. + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } })) + + const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')]) + const ctx = await harness(dir, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'use danger' }]) + await waitForIdle(ctx, agent) + + expect(ran).toBe(false) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('danger tool blocked'))).toBe(true) + }) + + it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const deny = join(dir, 'deny.sh') + writeFileSync(deny, '#!/usr/bin/env bash\nexit 2\n') + chmodSync(deny, 0o755) + // Matcher only targets "danger" — the "safe" tool is untouched. + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } })) + + const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')]) + const ctx = await harness(dir, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'use safe' }]) + await waitForIdle(ctx, agent) + + expect(ran).toBe(true) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(false) + }) +}) + +describe('hooks-claude bridge — PostToolUse', () => { + it('a PostToolUse hook that blocks (exit 2) turns the result into an isError with feedback', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const block = join(dir, 'block.sh') + writeFileSync(block, '#!/usr/bin/env bash\necho "output rejected, retry" >&2\nexit 2\n') + chmodSync(block, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: block }] }] } })) + + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(dir, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + const result = events(agent).find(e => e.type === 'tool/result') + // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback. + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true) + }) + + it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const s = join(dir, 'ctx.sh') + writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"note: tool was slow"}}\'\n') + chmodSync(s, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] } })) + + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(dir, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + const log = events(agent) + const resultIdx = log.findIndex(e => e.type === 'tool/result') + const ctxIdx = log.findIndex(e => e.type === 'context/message') + expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result + const ctxMsg = log[ctxIdx] + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) + }) + + it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const s = join(dir, 'ask.sh') + writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs approval"}}\'\n') + chmodSync(s, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] } })) + + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(dir, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. + expect(ran).toBe(false) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('needs approval'))).toBe(true) + }) +}) + +describe('hooks-claude bridge — SessionStart', () => { + it('a SessionStart hook injects additionalContext the first request sees', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + const s = join(dir, 'start.sh') + writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"project uses tabs"}}\'\n') + chmodSync(s, 0o755) + // matcher 'startup' selects the startup source. + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: s }] }] } })) + + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // session-start fires async (detached .then → agent.inject); wait for the + // injected context/message to actually land before sending, rather than a + // fixed sleep that flakes under load. + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs') + }) +}) + +describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => { + it('runs SubagentStart and SubagentStop hooks when the subagent lifecycle events fire', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) + dirs.push(dir) + // Each hook touches a marker file so we can assert it ran (these events are + // observe-only — there is no decision to assert, only the side effect). + const startMarker = join(dir, 'start-ran') + const stopMarker = join(dir, 'stop-ran') + const startHook = join(dir, 'start.sh') + const stopHook = join(dir, 'stop.sh') + writeFileSync(startHook, `#!/usr/bin/env bash\ntouch "${startMarker}"\n`) + writeFileSync(stopHook, `#!/usr/bin/env bash\ntouch "${stopMarker}"\n`) + chmodSync(startHook, 0o755) + chmodSync(stopHook, 0o755) + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { + SubagentStart: [{ hooks: [{ type: 'command', command: startHook }] }], + SubagentStop: [{ hooks: [{ type: 'command', command: stopHook }] }], + } })) + + const adapter = new MockAdapter([]) + const ctx = await harness(dir, adapter) + // Drive the observe-only lifecycle events directly (no real child needed — the + // bridge just listens). The agents registry is absent here, so SubagentStart's + // child lookup yields undefined and it simply runs the hook. + ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) + ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + + // Both hooks run async (detached .then); poll for their marker files rather + // than a fixed sleep that flakes under load. + const { existsSync } = await import('node:fs') + await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) + expect(existsSync(startMarker)).toBe(true) + expect(existsSync(stopMarker)).toBe(true) + }) +}) + +describe('hooks-claude bridge — load resilience', () => { + it('a missing config file registers no hooks and does not crash the loop', async () => { + const adapter = new MockAdapter([textResponse('fine')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // The turn ran normally — no hooks, no crash. + expect(adapter.requests).toHaveLength(1) + }) + + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it + // would veto the prompt (0 model requests) and log a hook/invoked. Build the + // ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then + // dispose it — a leaked listener fails the test (a no-op `true` hook would + // pass even leaked, so it proved nothing). + const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) + await fiber.dispose() + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone + expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran + }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { + // Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray + // `export default apply` would collapse the module via `unwrapExports` + // (`exports.default ?? exports`), DROP `inject`, and crash at load with + // "cannot get property … without inject". Guard the shape directly. + expect('default' in HooksClaude).toBe(false) + expect(HooksClaude.name).toBe('hooks-claude') + expect(HooksClaude.inject).toEqual(['bash']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(HooksClaude) as Record<string, unknown> + expect(unwrapped).toBe(HooksClaude) + expect(unwrapped.name).toBe('hooks-claude') + expect(unwrapped.inject).toEqual(['bash']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts new file mode 100644 index 0000000000..f635ef0fd9 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' + +describe('substituteCommand', () => { + it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => { + expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x.sh', { pluginRoot: '/p' })).toBe('/p/x.sh') + expect(substituteCommand('${CLAUDE_PROJECT_DIR}/a ${CLAUDE_PROJECT_DIR}/b', { projectDir: '/proj' })).toBe('/proj/a /proj/b') + expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}-${CLAUDE_PROJECT_DIR}', { pluginRoot: '/p', projectDir: '/d' })).toBe('/p-/d') + }) + it('leaves the command untouched when no vars are supplied', () => { + expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x', {})).toBe('${CLAUDE_PLUGIN_ROOT}/x') + }) +}) + +describe('parseClaudeConfig', () => { + it('parses a bare event map and a settings-style { hooks: … } wrapper identically', () => { + const groups = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'x.sh' }] }] } + const bare = parseClaudeConfig(groups) + const wrapped = parseClaudeConfig({ hooks: groups }) + expect(bare.config).toEqual(wrapped.config) + expect(bare.config.PreToolUse).toEqual([{ matcher: 'Bash', hooks: [{ command: 'x.sh' }] }]) + }) + + it('carries timeout → timeoutSec and substitutes the command', () => { + const { config } = parseClaudeConfig( + { Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/s.sh', timeout: 30 }] }] }, + { pluginRoot: '/p' }, + ) + expect(config.Stop).toEqual([{ hooks: [{ command: '/p/s.sh', timeoutSec: 30 }] }]) + }) + + it('skips non-command hooks (recorded) and keeps the command ones in the same group', () => { + const { config, skipped } = parseClaudeConfig({ + PreToolUse: [{ hooks: [ + { type: 'prompt', prompt: 'hi' }, + { type: 'command', command: 'ok.sh' }, + { type: 'http', url: 'http://x' }, + ] }], + }) + expect(config.PreToolUse).toEqual([{ hooks: [{ command: 'ok.sh' }] }]) + expect(skipped).toEqual([{ event: 'PreToolUse', type: 'prompt' }, { event: 'PreToolUse', type: 'http' }]) + }) + + it('treats a hook with no `type` as a command (CC default)', () => { + const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ command: 'd.sh' }] }] }) + expect(config.Stop).toEqual([{ hooks: [{ command: 'd.sh' }] }]) + }) + + it('drops malformed entries without throwing: non-array groups, non-object group/hook, missing command, empty groups', () => { + expect(parseClaudeConfig({ PreToolUse: 'nope' }).config).toEqual({}) + expect(parseClaudeConfig({ PreToolUse: [42, { hooks: 'no' }, { hooks: [7, { type: 'command' }] }] }).config).toEqual({}) + // a group whose only hook lacks a command string drops the whole (empty) group + expect(parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({}) + }) + + it('returns empty for a non-object / null / array top level', () => { + expect(parseClaudeConfig(null).config).toEqual({}) + expect(parseClaudeConfig(42).config).toEqual({}) + expect(parseClaudeConfig([1, 2]).config).toEqual({}) + }) + + it('omits the matcher key when the group has none (match-all)', () => { + const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] }) + expect('matcher' in config.Stop![0]!).toBe(false) + }) +}) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts new file mode 100644 index 0000000000..f06cdcf6b4 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -0,0 +1,700 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent + * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } +async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksClaude, { configPath, ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { + it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { + const d = dir() + // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. + const marker = join(d, 'ran') + sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { + PreToolUse: [{ hooks: [ + { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop + { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted + ] }], + }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) + ctx.logger.warn = warn as never + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) // substituted command ran + }) + + it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { + const d = dir() + const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.logger.warn = warn as never + let sawArgs: unknown + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // updatedInput is NOT honored — the tool ran with the ORIGINAL args. + expect((sawArgs as { command?: string }).command).toBe('original') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput')) + }) +}) + +describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => { + it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // The prompt proceeded unchanged; no context/message injected. + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + expect(ran).toBe(false) + expect(result.isError).toBe(true) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + // Emit >500 chars of stderr then exit 2. + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + const path = hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) +}) + +describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => { + it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => { + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') + }) + + it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { + // Regression: a blocking Stop hook (exit 2) with no stderr yields decision + // 'deny' + reason undefined; the turn must STILL force-continue (the block is + // what matters), not silently stop. Self-limit to one block so it can't loop. + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // A second model request ran → the empty-reason block forced continuation. + expect(adapter.requests).toHaveLength(2) + // The steering carried the fallback reason (no stderr to use). + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => { + const d = dir() + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + // Register a fake child agent under the id the event carries. + const injected: string[] = [] + const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + await waitFor(() => injected.includes('child guidance')) + expect(injected).toContain('child guidance') + }) + + it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => { + const d = dir() + // A hook command that does not exist makes runHook resolve a non-blocking + // error (not a throw), so to hit the .catch we make the .then throw: register + // a child whose inject throws for SubagentStart. + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + const warn = vi.fn(); ctx.logger.warn = warn as never + const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) + }) +}) + +describe('hooks-claude coverage — default reasons + sparse payloads', () => { + it('PreToolUse deny with EMPTY stderr uses the default reason', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => { + const d = dir() + // The agents registry has no entry for the id, so the child lookup yields + // undefined and the payload falls back to base(undefined) — assert the + // observe-only SubagentStop run still executes the hook without crashing. + const marker = join(d, 'stopran') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) + }) +}) + +describe('hooks-claude coverage — more default/sparse arms', () => { + it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') + }) + + it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { + const d = dir() + const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // ask (no reason) → degrades to deny with the registry's generic message. + expect(ran).toBe(false) + expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true) + }) + + it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) +}) + +describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { + it('a direct apply() (schema bypass) with only configPath runs', async () => { + const d = dir() + const marker = join(d, 'ran') + const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + // Direct apply with only configPath — bypasses schemastery's defaults, so + // the bridge must run on the raw minimal config (the per-hook timeout is + // the protocol lib's reference default, not a config knob). + HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + }) + + it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => { + const d = dir() + // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not + // 2 → no decision), so the tool proceeds; the hook/result records exit 127. + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127) + }) + + it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + }) +}) + +describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { + it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // Honoring `continue:false` (hard-halt the whole run) is deferred — there is + // no such primitive on the interception seams yet. So this asserts the LOG + // faithfully records the halt request (decision "stop"), AND that the run is + // NOT actually halted: the tool still runs and the turn completes normally. + const d = dir() + const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion + }) + + it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => { + const d = dir() + const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + // additionalContext also injected (the block + context arm). + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + }) + + it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { + // The block's hookEventName (UserPromptSubmit) mismatches the firing event + // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs. + const d = dir() + const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran + }) + + it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => { + // The default ACP wiring sets no projectDir. A stock CC hook that references + // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace, + // not an empty string. The hook echoes the var as additionalContext. + const d = dir() + const workspace = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) // NB: no projectDir + // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) + await handle.dispose() + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // A hook that only adds context must NOT short-circuit the waterfall: a + // downstream agent/prompt-submit listener (a policy plugin) must still get to + // block the prompt. The bridge delegates via next() and folds its context. + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(path, adapter) + // A later listener that blocks every prompt (registered AFTER the bridge). + const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // the downstream block won: the model was never called, no user/message was + // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + // Both the bridge hook and a later prompt-submit listener attach context; the + // request must see BOTH (concatContext keeps the downstream one too). + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const req = JSON.stringify(adapter.requests[0]!.messages) + expect(req).toContain('from-bridge') + expect(req).toContain('from-downstream') + expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved + // the original prompt was replaced by the downstream rewrite + const userMsg = events(agent).find(e => e.type === 'user/message') + expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + // The bridge hook adds context; a later post-execute listener accepts with a + // content rewrite. Both the rewrite and the bridge context survive. + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { + // The bridge hook only adds context; a later post-execute listener blocks the + // result. The block wins AND carries the bridge context (concatContext on the + // block arm). + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + // the bridge's context still landed (folded onto the block) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + +}) + +describe('hooks-claude coverage — executor reject + no-open-turn', () => { + it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => { + const d = dir() + const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + // Force the executor to reject (an infrastructure fault) so runHook's catch + // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. + const bash = ctx.bash + bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + +}) + +describe('hooks-claude coverage — detached-listener catch handlers', () => { + it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => { + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Make inject throw, forcing the SessionStart .catch path. + const original = agent.inject.bind(agent) + let threw = false + agent.inject = (() => { threw = true; throw new Error('inject boom') }) + await waitFor(() => threw) + expect(threw).toBe(true) + agent.inject = original + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject + }) +}) + +describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { + it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { + // The bug: the bridge passed no workdir, so hooks ran in the executor default + // (the server launch dir), not session/new.cwd. Here the executor default and + // the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a + // marker and we assert it ran in the SESSION cwd. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + // The hook is invoked with cwd = session dir, so a relative marker path lands there. + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + 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: [] }) + // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + + expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) + + it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { + // SubagentStop looks the child up (recoverable at subagent/end) and runs the + // hook in the CHILD's session cwd, not the executor default. Here the executor + // default and the child session cwd are DIFFERENT dirs; a SubagentStop hook + // writes `pwd` to a relative marker and we assert it landed in the CHILD dir — + // which only holds if the listener threaded the child agent into runPoint. + const serverDir = dir() + const childDir = dir() + const marker = join(childDir, 'stopwhere') + hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) + 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: [] }) + // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + + // Register a live child on its own session cwd; emit subagent/end with its id. + const { SessionId } = await import('@deepseek-ai/dsh-session') + const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) + await childHandle.dispose() + }) +}) + +describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => { + it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => { + const d = dir() + const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + // Not surfaced: the systemMessage text never reaches the model request. + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) +}) + +describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { + it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { + // Regression for the documented downgrade: session-start injection is + // detached, so a prompt sent immediately need not observe it. This asserts + // the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the + // inject first — it documents the best-effort timing rather than masking it + // by pre-waiting for context/message (which the guaranteed-timing tests do). + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Send immediately — do NOT wait for the session-start inject. + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing + }) +}) diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json new file mode 100644 index 0000000000..909db9b5c3 --- /dev/null +++ b/packages/hooks/hooks-claude/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../hook-protocol" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/session" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md new file mode 100644 index 0000000000..fa060b46af --- /dev/null +++ b/packages/hooks/hooks-codex/README.md @@ -0,0 +1,59 @@ +# @deepseek-ai/dsh-hooks-codex + +A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping. + +Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape): + +- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks. +- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). +- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. +- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim). +- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve. + +A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). + +## Config + +```ts +import type { Config } from '@deepseek-ai/dsh-hooks-codex' +const config: Config = { + configPath: '/path/to/.codex/hooks.json', // required + model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`) + defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary +} +``` + +In a `cordis.yml`: + +```yaml +- dsh-hooks-codex: + configPath: ./.codex/hooks.json + model: deepseek-v4 +``` + +The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse. + +The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir. + +## Hook points → seam Decisions + +| Codex hook | Harness seam | Mapping | +|---|---|---| +| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` | +| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) | +| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering | + +A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers. + +## Context source + +Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`). + +## Deferred + +**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands. + +**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`). diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json new file mode 100644 index 0000000000..f26b57fe11 --- /dev/null +++ b/packages/hooks/hooks-codex/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-hooks-codex", + "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-hook-protocol": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts new file mode 100644 index 0000000000..411f058eea --- /dev/null +++ b/packages/hooks/hooks-codex/src/config.ts @@ -0,0 +1,79 @@ +/** + * Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's + * config format is a SUBSET of Claude Code's: the same event-name → matcher-group + * structure and the same `{ type: 'command', command, timeout?/timeoutSec? }` + * hook shape, but only five events and NO command-string substitution (Codex sets + * no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's + * `async: true` commands) are parsed-and-skipped with a warning. + * + * @module @deepseek-ai/dsh-hooks-codex/config + */ + +import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' + +/** The five hook points Codex's engine supports. */ +export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const + +/** A parsed Codex config: event name → its matcher groups (command hooks only). */ +export type CodexHookConfig = Record<string, MatcherGroup[]> + +/** A skipped non-command (or async) hook, surfaced so the bridge can warn. */ +export interface SkippedHook { + event: string + reason: string +} + +/** The outcome of parsing one Codex config file. */ +export interface ParsedCodexConfig { + config: CodexHookConfig + skipped: SkippedHook[] +} + +function asObject(value: unknown): Record<string, unknown> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : undefined +} + +/** + * Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s. + * Only the five {@link CODEX_EVENTS} are honored; an unknown event is dropped. + * `type !== 'command'` and `async: true` command hooks are skipped (recorded in + * `skipped`). Malformed entries are ignored rather than thrown — a bad config + * must not crash boot. No command substitution (Codex does none). + */ +export function parseCodexConfig(raw: unknown): ParsedCodexConfig { + const config: CodexHookConfig = {} + const skipped: SkippedHook[] = [] + const root = asObject(raw) + const hooksMap = root ? asObject(root.hooks) ?? root : undefined + if (!hooksMap) return { config, skipped } + + for (const event of CODEX_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } + if (typeof hook.command !== 'string') continue + // Codex accepts `timeout` or the `timeoutSec` alias. + const timeout = typeof hook.timeout === 'number' ? hook.timeout + : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined + commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) + } + if (commands.length === 0) continue + groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands }) + } + if (groups.length > 0) config[event] = groups + } + + return { config, skipped } +} diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts new file mode 100644 index 0000000000..8c15ba5a87 --- /dev/null +++ b/packages/hooks/hooks-codex/src/index.ts @@ -0,0 +1,316 @@ +/** + * `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex + * `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT + * half of the hooks subsystem. + * + * Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points + * (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no + * subagent/notification/compaction), regex-only matchers, snake_case stdin + * payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and + * no command substitution, and a block-only decision model (allow/ask are not + * honored — a hook can only block, never pre-approve). The dialect-agnostic + * primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the + * Codex-specific payloads + matcher mode + decision mapping. + * + * @module @deepseek-ai/dsh-hooks-codex + */ + +import { readFileSync } from 'node:fs' +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { + appendHookInvoked, + appendHookResult, + DEFAULT_HOOK_TIMEOUT_MS, + DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, + mergeHookOutputs, + runHook, + type HookOutput, + type MatcherGroup, + type MergedHookOutcome, +} from '@deepseek-ai/dsh-hook-protocol' +import { parseCodexConfig, type CodexHookConfig } from './config.ts' + +export const name = 'hooks-codex' +export const inject = ['bash'] + +/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ +export interface Config { + /** + * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative + * path resolves against the process launch cwd. + * TODO(per-session-hook-config): per-session project-local discovery from each + * `session/new.cwd` is not yet implemented. + */ + configPath: string + /** The model name stamped on every payload (Codex includes `model` on each event). */ + model?: string + /** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */ + defaultTimeoutMs?: number + /** Character cap for the `hook/result` event's persisted stderr summary. */ + stderrSummaryMaxChars?: number +} + +export const Config: z<Config> = z.object({ + configPath: z.string().required(), + model: z.string().default(''), + defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS), + stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS), +}) + +let handlerCounter = 0 +function nextHandlerId(point: string): string { + return `codex:${point}:${++handlerCounter}` +} + +const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' } + +/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`hooks-codex: ${name} must be a positive integer`) + } +} + +export function apply(ctx: Context, config: Config): void { + // Validate the cap BEFORE the config-file parse: a bad value must fail the + // load loudly, not be skipped by the parse-failure early return. + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS + assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) + const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS + let parsed: CodexHookConfig = {} + try { + const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) + const result = parseCodexConfig(raw) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) + } + } catch (error: unknown) { + ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) + return + } + + const model = config.model ?? '' + + async function runPoint( + point: string, + matchQuery: string, + payload: unknown, + opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean }, + ): Promise<MergedHookOutcome> { + const groups: MatcherGroup[] = parsed[point] ?? [] + const outputs: HookOutput[] = [] + // Run the hook in the agent's session workspace (the `session/new` cwd), not + // the executor default (the server launch dir) — a hook reading a relative + // file or `pwd` must see the user's project tree. Absent for a no-agent run. + const workdir = opts.agent?.session.header.cwd + for (const group of groups) { + // Codex matches with PURE regex (no literal fast path). + if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue + for (const hook of group.hooks) { + const handlerId = nextHandlerId(point) + const session = opts.agent?.session + if (session && opts.turn !== undefined) { + appendHookInvoked(session, { + turn: opts.turn, point, dialect: 'codex', handlerId, + ...group.matcher !== undefined ? { matcher: group.matcher } : {}, + }) + } + const { output, durationMs } = await runHook(ctx.bash, hook, { + payload, + defaultTimeoutMs, + ...workdir !== undefined ? { cwd: workdir } : {}, + ...opts.signal ? { signal: opts.signal } : {}, + trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline. + // Discard a `hookSpecificOutput` block naming a different event. + expectedEventName: point, + }, () => performance.now()) + // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN + // (non-JSON) stdout as additionalContext. The codec keeps that raw text on + // `output.stdout` but only sets `additionalContext` from a JSON + // `hookSpecificOutput`, so fold plain stdout in here and let the shared + // merge + contextFrom path carry it. Gated exactly like the codec's own + // structured-stdout parse: only on a clean `exitCode === 0` (a non-zero + // exit is an error, not context — an `echo x; exit 2` must not inject + // `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured + // hook's raw JSON is never dumped as prose), and never clobbering an + // explicit additionalContext from a JSON block. + if (opts.plainStdoutAsContext === true && output.exitCode === 0 + && output.additionalContext === undefined + && output.stdout.length > 0 && !output.stdout.startsWith('{')) { + output.additionalContext = output.stdout + } + outputs.push(output) + if (output.systemMessage !== undefined) { + ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) + } + if (session && opts.turn !== undefined) { + appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs }) + } + } + } + return mergeHookOutputs(outputs) + } + + // TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from + // a hook's `continue:false`, but no seam below honors it — there is no + // "hard-halt the whole agent" primitive on the interception seams yet. Deferred + // with the loop-guard work; until then a `continue:false` hook keeps its + // per-point effect and the halt request is recorded in `hook/result`, not acted on. + + function contextFrom(merged: MergedHookOutcome): HookContext | undefined { + if (merged.additionalContext.length === 0) return undefined + const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text })) + return { content, source: PLUGIN_SOURCE } + } + + /** + * Concatenate this bridge's {@link HookContext} (`ours`, always present at the + * call sites) with a downstream listener's optional one, so folding our + * additionalContext onto a delegated decision drops neither. The merged block + * carries a single `source` — this bridge's — because a `HookContext` holds one + * `MessageSource` and the seam cannot represent mixed provenance; the rendered + * `context/message` only distinguishes by `source.kind` ('plugin'), so a + * downstream plugin's text is still correctly framed as plugin context. + */ + function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { + if (!theirs) return ours + return { content: [...ours.content, ...theirs.content], source: ours.source } + } + + // SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext. + // TODO(session-start-gating): a synchronous emit + detached `.then`, so the + // injected context is BEST-EFFORT — not guaranteed before the first turn reaches + // the model (a slow hook can miss the first request). Gating is a deferred + // loop-level change; the contract is "injected as soon as the hook resolves". + ctx.on('agent/session-start', (agent, source) => { + void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true }) + .then((merged) => { + const context = contextFrom(merged) + if (context) agent.inject(context.content, { source: context.source }) + }) + .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }) + }) + + // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask). + ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => { + const turn = lastTurn(agent) + const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } + // Context alone is not a veto: DELEGATE so a later prompt-submit listener can + // still block/rewrite, then fold our context onto its decision. + const downstream = await next() + const ours = contextFrom(merged) + if (!ours || downstream.kind !== 'allow') return downstream + return { + kind: 'allow', + ...downstream.content !== undefined ? { content: downstream.content } : {}, + additionalContext: concatContext(ours, downstream.additionalContext), + } + }) + + // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). + ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { + const turn = lastTurn(exec.agent) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } + return next() + }) + + // PostToolUse → PostToolDecision (block with feedback, or attach context). + ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => { + const turn = lastTurn(exec.agent) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const context = contextFrom(merged) + if (merged.decision === 'deny') { + return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} } + } + // Context alone is not a veto: DELEGATE, then fold our context onto the + // downstream decision (a downstream block carries it too). + const downstream = await next() + if (!context) return downstream + if (downstream.kind === 'block') { + return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) } + } + return { + kind: 'accept', + ...downstream.content !== undefined ? { content: downstream.content } : {}, + additionalContext: concatContext(context, downstream.additionalContext), + } + }) + + // Stop → ContinuationDecision. A blocking Stop hook forces continuation. + // TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would + // force-continue every step (`stop_hook_active` is always false here); the + // loop-guard (stop_hook_active + a max-consecutive cap) is deferred. + ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => { + const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) + if (merged.decision === 'deny') { + // A blocking Stop hook forces continuation; a block with no reason (exit 2, + // empty stderr) still forces it — fall back to a generic steering line + // rather than letting the turn stop. + const text = merged.reason ?? 'continue: blocked by Stop hook' + return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } } + } + return next() + }) +} + +// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on +// turn-scoped events. --- + +function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + const last = [...agent.session.events].findLast(e => e.type === 'turn/start') + /* v8 ignore next -- the `: 0` arm is a defensive fallback: when an agent is + present, lastTurn is only called from the mid-turn seams, which always run + inside an open turn, so `last` is always a turn/start here. */ + return last?.type === 'turn/start' ? last.data.turn : 0 +} + +function blocksToText(content: ContentBlock[]): string { + return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('') +} + +/** Base fields on every Codex payload (no turn_id). */ +function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> { + return { + session_id: agent?.session.header.id ?? '', + transcript_path: null, + cwd: agent?.session.header.cwd ?? process.cwd(), + hook_event_name: event, + model, + permission_mode: 'default', + } +} + +/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */ +function turnBase(agent: Agent | undefined, event: string, model: string): Record<string, unknown> { + return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } +} + +/** Extract a `command` string from a tool call's parsed arguments, else ''. */ +function commandOf(args: unknown): string { + if (typeof args === 'object' && args !== null && 'command' in args) { + const command: unknown = args.command + if (typeof command === 'string') return command + } + return '' +} + +function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> { + // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); + // a hardcoded constant would disagree with what the matcher tests and make a + // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` + // shape (its shell payload), derived from the call's `command` arg when present. + return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } +} + +function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> { + return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +} diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts new file mode 100644 index 0000000000..0148da104e --- /dev/null +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** + * Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash + + * REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`. + * Codex dialect specifics exercised here: regex matcher (substring), block-only + * decisions, the five-event subset. + */ + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +function configDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-')) + dirs.push(dir) + return dir +} +function script(dir: string, name: string, body: string): string { + const path = join(dir, name) + writeFileSync(path, body) + chmodSync(path, 0o755) + return path +} +function writeHooks(dir: string, hooks: unknown): void { + writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks })) +} + +async function harness(dir: string, adapter: MockAdapter): Promise<Context> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } + +describe('hooks-codex bridge', () => { + it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { + const dir = configDir() + const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') + // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". + writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) + + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(dir, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'run ls' }]) + await waitForIdle(ctx, agent) + + expect(ran).toBe(false) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true) + // recorded under the codex dialect + expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true) + }) + + it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => { + const dir = configDir() + // Block exactly ONCE (a marker file), then allow — without a one-shot guard a + // hook that always exits 2 would force-continue forever (the deferred + // stop_hook_active loop-guard is the real fix; here we self-limit so the test + // exercises the continue path without looping). + const marker = join(dir, 'fired') + const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`) + writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] }) + + // Step 1 has no tool calls → would stop; the Stop hook forces step 2. + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // The Stop hook's reason became next-step steering → a second model request ran. + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') + }) + + it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => { + const dir = configDir() + const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n') + // SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect). + writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) + + const adapter = new MockAdapter([textResponse('fine')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // Ran normally; the unknown event was dropped at parse. + expect(adapter.requests).toHaveLength(1) + }) + + it('a missing config registers no hooks and does not crash', async () => { + const dir = configDir() // no hooks.json written + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) + }) + + it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { + const dir = configDir() + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it + // would veto the prompt (0 model requests) and log a hook/invoked. After a + // clean dispose the turn must proceed untouched — this fails loudly on a leak + // (a no-op `true` hook would pass even with a leaked listener). + const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') + writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) + await fiber.dispose() + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone + expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran + }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { + expect('default' in HooksCodex).toBe(false) + expect(HooksCodex.name).toBe('hooks-codex') + expect(HooksCodex.inject).toEqual(['bash']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(HooksCodex) as Record<string, unknown> + expect(unwrapped).toBe(HooksCodex) + expect(unwrapped.name).toBe('hooks-codex') + expect(unwrapped.inject).toEqual(['bash']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts new file mode 100644 index 0000000000..e79d665931 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' + +describe('parseCodexConfig', () => { + it('honors only the five Codex events, dropping unknown ones', () => { + const { config } = parseCodexConfig({ + PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }], + SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event + Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event + }) + expect(Object.keys(config)).toEqual(['PreToolUse']) + expect(CODEX_EVENTS).toContain('PreToolUse') + expect(CODEX_EVENTS).not.toContain('SubagentStop' as never) + }) + + it('accepts both timeout and the timeoutSec alias, no substitution', () => { + const { config } = parseCodexConfig({ + Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }], + }) + // Codex does NO substitution — the literal ${…} survives. + expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }]) + expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }]) + }) + + it('skips non-command and async:true hooks (recorded)', () => { + const { config, skipped } = parseCodexConfig({ + PreToolUse: [{ hooks: [ + { type: 'prompt' }, + { type: 'command', command: 'sync.sh' }, + { type: 'command', command: 'bg.sh', async: true }, + ] }], + }) + expect(config.PreToolUse).toEqual([{ hooks: [{ command: 'sync.sh' }] }]) + expect(skipped).toEqual([{ event: 'PreToolUse', reason: 'unsupported "prompt" hook' }, { event: 'PreToolUse', reason: 'async hook' }]) + }) + + it('parses the { hooks: … } wrapper and the bare map identically', () => { + const groups = { Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] } + expect(parseCodexConfig(groups).config).toEqual(parseCodexConfig({ hooks: groups }).config) + }) + + it('drops malformed entries and a non-object top level without throwing', () => { + expect(parseCodexConfig(null).config).toEqual({}) + expect(parseCodexConfig({ PreToolUse: 'no' }).config).toEqual({}) + expect(parseCodexConfig({ Stop: [7, { hooks: 'x' }, { hooks: [{ type: 'command', command: 9 }] }] }).config).toEqual({}) + }) + + it('skips a non-object element inside a hooks array, keeping the valid sibling', () => { + const { config } = parseCodexConfig({ Stop: [{ hooks: [null, 7, { type: 'command', command: 's.sh' }] }] }) + expect(config.Stop).toEqual([{ hooks: [{ command: 's.sh' }] }]) + }) + + it('treats a hook with no `type` field as a command (the default)', () => { + const { config } = parseCodexConfig({ Stop: [{ hooks: [{ command: 's.sh' }] }] }) + expect(config.Stop).toEqual([{ hooks: [{ command: 's.sh' }] }]) + }) + + it('omits the matcher key for a match-all group', () => { + const { config } = parseCodexConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] }) + expect('matcher' in config.Stop![0]!).toBe(false) + }) + + it('keeps a matcher when present', () => { + const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') + }) +}) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts new file mode 100644 index 0000000000..c83137df53 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -0,0 +1,563 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> { + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +describe('hooks-codex coverage — decision mapping paths', () => { + it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') + }) + + it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // Context alone is not a veto: a downstream agent/prompt-submit listener (a + // policy plugin registered after the bridge) must still get to block. The + // bridge delegates via next() and folds its context onto the decision. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const req = JSON.stringify(adapter.requests[0]!.messages) + expect(req).toContain('from-bridge') + expect(req).toContain('from-downstream') + expect(req).toContain('rewritten-prompt') + }) + + it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + it('SessionStart additionalContext is injected for the first request', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') + }) + + it('PostToolUse block (exit 2) → isError feedback; default reason', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('PostToolUse additionalContext (clean exit) is attached after the result', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + }) + + it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' + }) + + it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) + + it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { + const d = dir() + const marker = join(d, 'ran') + hooks(d, { UserPromptSubmit: [{ hooks: [ + { type: 'command', command: 'bg.sh', async: true }, // skipped → warn + { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) }, + ] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + ctx.logger.warn = warn as never + // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. + HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) + }) + + it('a no-op clean hook proceeds (contextFrom empty → next)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => { + const d = dir() + // The hook touches a marker so we can wait for it to ACTUALLY FINISH before + // asserting absence — a completed turn alone would not prove the detached + // session-start hook ran, making the absence check a false pass. + const marker = join(d, 'ss-ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the clean no-output hook has finished + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a throwing SessionStart inject is contained (logged)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.inject = (() => { throw new Error('inject boom') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) + }) + + it('a clean PreToolUse with no decision allows the tool (no deny)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => { + const d = dir() + // /^Edit$/ does not match the tool name "Bash" → the group is skipped. + hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded + expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) + }) + + it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // Honoring `continue:false` is deferred — the seams have no hard-halt + // primitive. Assert the LOG records the halt request AND that the run is not + // actually halted (the tool still runs, the turn completes). + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + }) + + it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse block AND additionalContext are surfaced together', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + }) + + it('commandOf reads a non-string command arg as an empty command', async () => { + const d = dir() + // The tool-call arguments carry `command` as a NUMBER → commandOf's + // `typeof command === 'string'` false arm → '' (the payload's tool_input.command). + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } + expect(payload.tool_input.command).toBe('') + }) + + it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + expect(ran).toBe(false) // denied + expect(result.isError).toBe(true) + }) + + it('a no-agent direct PostToolUse run attaches context with no session to record', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + expect(result.isError).toBeFalsy() + expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) + }) + + it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + + it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => { + // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' + + // reason undefined; the turn must STILL force-continue, not silently stop. + const d = dir() + const marker = join(d, 'fired') + hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => { + // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout + // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') + }) + + it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { + // The plain-stdout→context fold is gated on exitCode === 0, matching the + // codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so + // an `echo stale; exit 2` here is the exact case the gate guards: without it, + // the non-clean hook's stdout would wrongly inject "stale". A marker lets us + // wait for the detached hook to finish before asserting absence. + const d = dir() + const marker = join(d, 'ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the exit-2 hook has finished + expect(events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) + }) + + it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => { + // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked + // and the handler falls through to the context path — the gate must still + // suppress the error hook's stdout ("stale" never reaches the model). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') + }) + + it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') + }) + + it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => { + // A structured (JSON) stdout must go through the hookSpecificOutput path, not + // be dumped verbatim as context — the `!startsWith('{')` gate guards this. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') + }) + + it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => { + // Regression: the payload once hardcoded tool_name "Bash", disagreeing with + // the exec.name matcher subject — a config matcher on the real name would + // then never fire. Capture the payload and assert tool_name === the real name. + const d = dir() + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } + expect(payload.tool_name).toBe('shell') + expect(payload.tool_input.command).toBe('ls') + }) + + it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => { + // A regex matcher matching the real tool name must select the hook — proving + // the matcher subject and the payload tool_name agree. + const d = dir() + hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(false) // the matcher fired → the hook denied the tool + expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) + }) + + it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) + + it('runs an agent-scoped hook in the session cwd, not the executor default', async () => { + // Same regression as the CC bridge: the Codex bridge must thread the session + // cwd as the hook workdir. Executor default = serverDir; session cwd = + // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = new Context() + await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(existsSync(marker)).toBe(true) + expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) +}) diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json new file mode 100644 index 0000000000..f936b500aa --- /dev/null +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../hook-protocol" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../bash/bash" + } + ] +} diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 76af182580..15fad9f881 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -23,18 +23,19 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. +## App attribution + +Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. + ## Wire-format notes (verified live + against the official docs) - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). -- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Limitations (MVP, documented deliberately) -- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work. -- `image` blocks are skipped (no vision support on these models). - `tool_choice` is not mapped (not part of the core vocabulary). ## Errors diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index fda527359a..9e5e5179b6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,7 +5,7 @@ * @module dsh-llm-deepseek/adapter */ -import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -21,13 +21,6 @@ export interface DeepSeekAdapterOptions { defaults?: RequestDefaults } -/** - * Attribution header sent on every request so the provider can identify the - * client. Bump in lockstep with this package's version (no build-time version - * injection is wired in this repo yet). - */ -const USER_AGENT = 'deepseek-harness/0.0.1' - /** Map an HTTP status to a stable LlmError code. */ export function httpErrorCode(status: number): string { if (status === 401 || status === 403) return 'AUTH' @@ -67,7 +60,7 @@ export class DeepSeekAdapter extends LlmAdapter { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', 'accept': 'text/event-stream', - 'user-agent': USER_AGENT, + ...attributionHeaders(), }, body: JSON.stringify(body), ...options.signal ? { signal: options.signal } : {}, diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 4e967d6667..bbca37223f 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -11,12 +11,10 @@ * rule for thinking mode — required there, ignored elsewhere, so we save * the tokens elsewhere); `tool-call` → `tool_calls[]` * - `tool-result` → its own `{role: 'tool'}` message (text flattened) - * - `image` → skipped (MVP limitation, documented in the README) * * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -99,19 +97,8 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } -/** - * Build the full wire request. Throws `LlmError('UNSUPPORTED')` for - * `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a - * different base URL — see README). - */ +/** Build the full wire request. */ export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)', - 'UNSUPPORTED', - ) - } - const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) @@ -124,8 +111,6 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa name: tool.name, description: tool.description, parameters: tool.parameters, - // strict is officially supported (Beta); pass the tool author's choice. - ...tool.strict !== undefined ? { strict: tool.strict } : {}, }, })) diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 5c212897fd..072c43babb 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -78,8 +78,6 @@ export interface WireTool { name: string description: string parameters: Record<string, unknown> - /** Beta: strict schema adherence (official: requires the /beta base URL). */ - strict?: boolean } } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1abbebc060..46f123a1c7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { LlmError } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' @@ -109,8 +109,12 @@ describe('DeepSeekAdapter against a mock server', () => { stream: true, stream_options: { include_usage: true }, }) - // Attribution header identifies the harness to the provider. - expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//) + // Attribution reaches the wire: the exact shared User-Agent, and no + // provider-specific headers under the User-Agent-only contract. + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + expect(server.headers[0]).not.toHaveProperty('http-referer') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') }) it('streams raw chunks through ctx.llm.stream', async () => { diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 04387d7aa8..3e533f8e7c 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions { @@ -110,11 +110,17 @@ describe('serializeMessages', () => { ]) }) - it('skips image blocks (documented MVP limitation)', () => { + it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => { const wire = serializeMessages([ - { role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] }, + { + role: 'user', + content: [ + { type: 'chart', data: 'x' } as unknown as ContentBlock, + { type: 'text', text: 'see chart' }, + ], + }, ]) - expect(wire).toEqual([{ role: 'user', content: 'see image' }]) + expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) }) it('emits an empty user message rather than dropping block-less messages', () => { @@ -149,17 +155,17 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) - it('maps tools with strict passthrough', () => { + it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, tools: [ { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true }, + { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, ], })) expect(wire.tools).toEqual([ { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } }, + { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, ]) }) @@ -179,17 +185,6 @@ describe('serializeRequest', () => { expect(wire.thinking).toBeUndefined() expect(wire.reasoning_effort).toBeUndefined() }) - - it('rejects prefill with an UNSUPPORTED LlmError', () => { - expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] }))) - .toThrow(LlmError) - try { - serializeRequest(request({ prefill: [] })) - expect.unreachable() - } catch (error) { - expect((error as LlmError).code).toBe('UNSUPPORTED') - } - }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 70f61fdb62..f74ccd2246 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -9,7 +9,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht - pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`. - pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses). - pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map. -- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments). +- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments). ## Config @@ -25,13 +25,17 @@ Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking- reasoning: high # off | high | xhigh (xhigh → wire 'max') ``` +## App attribution + +Every request carries the shared attribution header from dsh-llm's `attributionHeaders()`, passed through pi-ai's `headers` stream option (pi-ai merges caller headers last, so it always reaches the wire - the unit suite asserts arrival on the mock server, same as llm-deepseek). OpenRouter-specific app attribution headers are intentionally not sent by this adapter contract; they are deferred to a future explicit OpenRouter adapter or mode. See [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts). + ## Dependency weight pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification. ## Limitations -Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped. +Same MVP contract as llm-deepseek: `tool_choice` is not mapped. ## Testing diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index e15cce8252..a24e335d8b 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,9 +13,9 @@ import { stream as piStream } from '@earendil-works/pi-ai' import type { Model } from '@earendil-works/pi-ai' -import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, LlmAdapter } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { toPiContext, toStreamChunks } from './convert.ts' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ @@ -61,7 +61,7 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model< } type Payload = { - tools?: { function?: { name?: unknown; strict?: unknown } }[] + tools?: { function?: { strict?: unknown } }[] messages?: { role?: unknown tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[] @@ -81,10 +81,6 @@ function rawToolArguments(options: GenerateOptions): Map<CallId, string> { return raw } -function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> { - return new Map((tools ?? []).map(tool => [tool.name, tool.strict])) -} - function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown { /* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */ if (typeof payload !== 'object' || payload === null) return payload @@ -97,16 +93,13 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA body.stop = options.stop } - const strictByName = strictByToolName(options.tools) + // pi-ai stamps its own `strict` default on every serialized tool; the + // harness tool contract has no strict field and the hand-rolled twin sends + // none, so scrub it for wire parity. for (const tool of body.tools ?? []) { /* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */ if (tool.function === undefined) continue - const name = tool.function.name - /* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */ - if (typeof name !== 'string') continue - const strict = strictByName.get(name) - if (strict === undefined) delete tool.function.strict - else tool.function.strict = strict + delete tool.function.strict } const rawById = rawToolArguments(options) @@ -131,9 +124,9 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA * * Implementation notes: * - `onPayload` patches provider payload details pi-ai cannot express directly: - * stop sequences, per-tool strict, omitted reasoning effort, and raw replayed - * tool-call arguments. - * - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek). + * stop sequences, scrubbing pi-ai's own per-tool `strict` default (the + * hand-rolled twin sends no such field), omitted reasoning effort, and raw + * replayed tool-call arguments. * - pi-ai reports request failures as in-stream error events; convert.ts * maps them to `finish {kind:'error'|'aborted'}` chunks rather than * throwing — both are sanctioned StreamChunk error paths. @@ -144,13 +137,6 @@ export class PiAiAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the pi-ai adapter', - 'UNSUPPORTED', - ) - } - const model = buildModel(options.model, this.options) // Undefined config means "provider default" (DeepSeek: thinking ENABLED), // matching llm-deepseek's omission semantics. pi-ai derives the wire @@ -171,6 +157,9 @@ export class PiAiAdapter extends LlmAdapter { try { const events = piStream(model, toPiContext(options), { apiKey: this.options.apiKey, + // pi-ai merges caller headers last over its provider defaults, so the + // harness attribution always reaches the wire. + headers: attributionHeaders(), ...options.temperature !== undefined ? { temperature: options.temperature } : {}, ...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}, signal: controller.signal, diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 0ddc41386a..6fa96a0597 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -93,7 +93,7 @@ export function toPiContext(options: GenerateOptions): PiContext { }) break default: - // image / plugin-added block types: not representable here. + // plugin-added block types: not representable here. break } } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63f9f90456..cefaa9f745 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' @@ -11,6 +11,8 @@ import { assemble } from './assemble.ts' interface MockServer { url: string requests: unknown[] + /** Header bags of received requests, in order (parallel to `requests`). */ + headers: IncomingMessage['headers'][] close(): Promise<void> } @@ -22,11 +24,13 @@ afterEach(async () => { async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise<MockServer> { const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) request.on('end', () => { requests.push(JSON.parse(body)) + headers.push(request.headers) const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } if (behavior.status !== undefined && behavior.status !== 200) { response.writeHead(behavior.status, { 'content-type': 'application/json' }) @@ -45,6 +49,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s return { url: `http://127.0.0.1:${address.port}`, requests, + headers, close: () => new Promise(resolve => server.close(() => { resolve() })), } } @@ -91,6 +96,14 @@ describe('PiAiAdapter against a mock server', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(result.finish).toEqual({ kind: 'stop' }) expect(result.usage).toMatchObject({ inputTokens: 3, outputTokens: 1 }) + + // Attribution reaches the wire through pi-ai's headers hook: the exact + // shared User-Agent, and no provider-specific headers under the + // User-Agent-only contract. + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + expect(server.headers[0]).not.toHaveProperty('http-referer') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') }) it('streams tool calls with re-stringified arguments', async () => { @@ -149,26 +162,26 @@ describe('PiAiAdapter against a mock server', () => { expect(server.requests[0]).toMatchObject({ stop: ['END'] }) }) - it('preserves per-tool strict exactly through onPayload', async () => { + it('scrubs pi-ai\'s own per-tool strict default through onPayload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], tools: [ - { name: 'strict_true', description: 'true', parameters: {}, strict: true }, - { name: 'strict_false', description: 'false', parameters: {}, strict: false }, - { name: 'strict_omitted', description: 'omitted', parameters: {} }, + { name: 'alpha', description: 'a', parameters: {} }, + { name: 'beta', description: 'b', parameters: {} }, ], }) + // pi-ai stamps `strict` on every serialized tool function; the harness + // contract has none and the hand-rolled twin sends no such field, so the + // payload fixup must have deleted it from every tool. const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] } - expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([ - ['strict_true', true], - ['strict_false', false], - ['strict_omitted', undefined], - ]) - expect('strict' in request.tools[2]!.function).toBe(false) + expect(request.tools.map(tool => tool.function.name)).toEqual(['alpha', 'beta']) + for (const tool of request.tools) { + expect('strict' in tool.function).toBe(false) + } }) it('preserves raw replayed tool-call arguments in the provider payload', async () => { @@ -209,15 +222,6 @@ describe('PiAiAdapter against a mock server', () => { expect(result.finish).toMatchObject({ kind: 'error', code }) }) - it('rejects prefill with UNSUPPORTED', async () => { - const ctx = await harness('http://127.0.0.1:1') - await expect(assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [], - prefill: [{ type: 'text', text: 'Sure' }], - })).rejects.toThrow(LlmError) - }) - it('registers/unregisters models on the llm service (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index be42c9e9b4..078d2a4d3b 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' @@ -171,13 +171,13 @@ describe('toPiContext', () => { expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult']) }) - it('skips image and unknown blocks in assistant content', () => { + it('skips plugin-added (unknown) blocks in assistant content', () => { const context = toPiContext({ model: 'm', messages: [{ role: 'assistant', content: [ - { type: 'image', url: 'data:,x' }, + { type: 'chart', data: 'x' } as unknown as ContentBlock, { type: 'text', text: 'visible' }, ], }], diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 4227f5fdef..ab5fab49be 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,10 +25,14 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Content-block vocabulary (`types.ts`) -Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. +Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. +### App attribution (`attribution.ts`) + +Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). + ### Classes - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts new file mode 100644 index 0000000000..61ee2f9ddd --- /dev/null +++ b/packages/llm/llm/src/attribution.ts @@ -0,0 +1,71 @@ +/** + * App-attribution vocabulary for provider requests. + * + * Every product LLM adapter must identify the application on every provider + * HTTP request (see the adapter contract on {@link ../index.ts LlmAdapter}): + * a static, non-secret product identity, sent as the standard `User-Agent`. + * Adapters obtain the headers from {@link attributionHeaders} instead of + * hand-copying constants, so the identity cannot drift between + * implementations. The policy and its rationale are pinned in + * docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md. + * + * @module @deepseek-ai/dsh-llm/attribution + */ + +import { createRequire } from 'node:module' + +// The package's own manifest is the single source of the version so the +// User-Agent cannot drift from what is published (`./package.json` is an +// export of this package; the relative path resolves from both `src/` and +// the bundled `lib/`). +const { version } = createRequire(import.meta.url)('../package.json') as { version: string } + +/** + * Static public application identity sent to LLM providers. + * + * Every field is a public product fact, safe on every request: no secrets, + * local paths, session ids, prompt text, or per-user identifiers belong here, + * and nothing per-request may influence the values. + */ +export interface AppIdentity { + /** `User-Agent` product token (lowercase, hyphenated). */ + product: string + /** Product version; sourced from package metadata, never hand-copied. */ + version: string + /** Public home URL of the app, used as the `User-Agent` comment. */ + url: string +} + +/** + * The harness's own identity: the default every adapter sends. Deployments + * that need a white-label identity pass their own {@link AppIdentity} to + * {@link attributionHeaders} — omission falls back to this default; nothing + * can suppress attribution entirely. + */ +export const APP_IDENTITY: AppIdentity = { + product: 'deepseek-harness', + version, + // FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this + // URL promises before the first release ships attribution pointing at it. + url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', +} + +/** + * The standard `User-Agent` value: `product/version (+url)`. The + * parenthesized `+url` comment is the conventional self-identification form + * (RFC 9110 §10.1.5 product + comment syntax). + */ +export function userAgent(identity: AppIdentity = APP_IDENTITY): string { + return `${identity.product}/${identity.version} (+${identity.url})` +} + +/** + * Build the attribution headers an adapter must send on every provider + * request. Header names are lowercase (HTTP field names are case-insensitive + * on the wire). + */ +export function attributionHeaders( + identity: AppIdentity = APP_IDENTITY, +): Record<string, string> { + return { 'user-agent': userAgent(identity) } +} diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 320838a8a6..220bb60062 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, StreamChunk } from './types.ts' import { HarnessError } from './error.ts' +export * from './attribution.ts' export * from './brand.ts' export * from './never.ts' export * from './error.ts' @@ -26,6 +27,7 @@ declare module 'cordis' { * Waterfall around every streaming model call (retry, caching, routing). * Bound to the {@link LlmService}; call `next()` to reach the resolved * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request; listeners may rewrite it before delegating. * @mode waterfall */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk> @@ -56,6 +58,13 @@ export class LlmError extends HarnessError { * fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two * deliberately different internals over the same contract; see the * adapter contract documented on `StreamChunk` in `./types.ts`. + * + * App attribution is part of the adapter contract: every HTTP request to a + * provider carries the headers from `attributionHeaders()` (`./attribution.ts`) + * — the standard `User-Agent` baseline everywhere. An adapter proves it with + * a wire-level test (a mock server asserting the received header), or, for a + * library-backed adapter, by asserting the library's header hook delivers the + * same value to the wire. */ export abstract class LlmAdapter { /** Stream one model call as raw chunks. The only required method. */ @@ -77,6 +86,9 @@ export class LlmService extends Service { * Register an adapter for the given model names. Throws `LlmError` with code * `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing). * Disposed with the fiber. + * @param models - every model name this adapter should serve. + * @param adapter - the adapter that streams calls for those models. + * @returns the disposer that unregisters all of them. */ registerAdapter(models: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { @@ -95,7 +107,10 @@ export class LlmService extends Service { return () => void dispose() } - /** Model names with a registered adapter. */ + /** + * Model names with a registered adapter. + * @returns the registered names, in registration order. + */ models(): string[] { return [...this.adapters.keys()] } @@ -110,6 +125,8 @@ export class LlmService extends Service { * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.model`. Dispatches through the `llm/stream` waterfall. + * @param options - the full request; `options.model` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable<StreamChunk> { return this.ctx.waterfall(this, 'llm/stream', options, () => { diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 7b1b9bdc47..7163ddc1d9 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -22,14 +22,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId } from './brand.ts' -/** Cache hint attached to a content block (provider-interpreted). */ -export type CacheHint = 'ephemeral' - /** Plain text visible to the end user. */ export interface TextBlock { type: 'text' text: string - cache?: CacheHint } /** Reasoning / thinking content, distinct from visible text. */ @@ -54,27 +50,24 @@ export interface ToolResultBlock { toolCallId: CallId content: ContentBlock[] isError?: boolean - cache?: CacheHint -} - -/** An image, by URL or data URL. */ -export interface ImageBlock { - type: 'image' - url: string - mimeType?: string - cache?: CacheHint } /** * All known content block shapes, keyed by their `type` tag. * Merge-extensible: plugins add new block types via declaration merging. + * + * The core set is deliberately limited to blocks every shipping path honors. + * Multimodal content (images, audio, …) has no core block type: a feature + * that needs one adds it via declaration merging in the same coordinated + * change that maps it in the adapters, surfaces it in the UI bridges, and + * prices it in compaction — a producer never lands without its consumers + * (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md). */ export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } export type ContentBlockType = keyof ContentBlockMap @@ -93,7 +86,6 @@ export interface Message { export interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } export type MessageSource = MessageSourceMap[keyof MessageSourceMap] @@ -171,7 +163,6 @@ export interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record<string, unknown> - strict?: boolean } /** A single model request, fully assembled. */ @@ -182,8 +173,6 @@ export interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index e8ad04e3b5..d9a4fe33f3 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -63,12 +63,12 @@ describe('BlockAssembler', () => { it('throws from assemble() when a partial has an unhandled blockType', () => { const assembler = new BlockAssembler() - // Directly push a block-end for an image block whose block-start never - // called ensure — but the image block-type flows through normally. - // What we really need is a partial whose blockType is not text/reasoning/tool-call. - // We can achieve this via a block-start for 'image' followed by blocks(). - assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk) - expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"') + // A partial whose blockType is not text/reasoning/tool-call cannot be + // assembled without its block-end. A plugin-added block type (here + // 'video', via the merge-extensible ContentBlockMap) opened by a + // block-start with no closing block-end exercises that throw. + assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk) + expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"') }) it('mustGet throws when an index is missing from the partials map (invariant violation)', () => { diff --git a/packages/llm/llm/tests/attribution.spec.ts b/packages/llm/llm/tests/attribution.spec.ts new file mode 100644 index 0000000000..e797af5b38 --- /dev/null +++ b/packages/llm/llm/tests/attribution.spec.ts @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { APP_IDENTITY, attributionHeaders, userAgent } from '@deepseek-ai/dsh-llm' +import type { AppIdentity } from '@deepseek-ai/dsh-llm' + +const manifest = createRequire(import.meta.url)('../package.json') as { version: string } + +/** A white-label identity exercising every override seam. */ +const forkIdentity: AppIdentity = { + product: 'fork-agent', + version: '9.9.9', + url: 'https://example.com/fork-agent', +} + +describe('APP_IDENTITY', () => { + it('sources the version from the package manifest, never a hand-copied constant', () => { + expect(APP_IDENTITY.version).toBe(manifest.version) + }) + + it('carries only static public product facts', () => { + expect(APP_IDENTITY).toEqual({ + product: 'deepseek-harness', + version: manifest.version, + url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + }) + }) +}) + +describe('userAgent', () => { + it('renders product/version with the +url comment', () => { + expect(userAgent()).toBe( + `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`, + ) + }) + + it('renders a custom identity', () => { + expect(userAgent(forkIdentity)).toBe('fork-agent/9.9.9 (+https://example.com/fork-agent)') + }) +}) + +describe('attributionHeaders', () => { + it('defaults to the provider-neutral baseline: User-Agent and nothing else', () => { + expect(attributionHeaders()).toEqual({ 'user-agent': userAgent() }) + }) + + it('maps a custom identity onto the User-Agent header only', () => { + expect(attributionHeaders(forkIdentity)).toEqual({ + 'user-agent': 'fork-agent/9.9.9 (+https://example.com/fork-agent)', + }) + }) +}) diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index c63d56abbb..3bb2a76c38 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -81,7 +81,7 @@ describe('BlockAssembler properties', () => { fc.assert(fc.property(streamArb, (chunks) => { const blocks = feed(chunks).blocks() for (const block of blocks) { - expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type) + expect(['text', 'reasoning', 'tool-call', 'tool-result']).toContain(block.type) } })) }) diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts index 7d27a2d0ee..e1e0a31545 100644 --- a/packages/session-fork/session-fork/src/index.ts +++ b/packages/session-fork/session-fork/src/index.ts @@ -73,6 +73,9 @@ export class SessionForkService extends Service { * Resolve and validate a live source session, then return a reusable deep- * cloned fork seed. A non-empty source must end exactly at `turn/end`; this * service rejects open turns rather than clipping to an older boundary. + * + * @param source Live session object or live store id to snapshot. + * @returns Deep-cloned seed events plus child session metadata. */ snapshot(source: SessionForkSource): SessionForkSeed { const session = this._resolve(source) @@ -93,6 +96,9 @@ export class SessionForkService extends Service { * Convenience path: create a live child session from a fork snapshot. Callers * that create agents can use {@link snapshot} and pass its seed/meta through * `ctx.agents.create` instead. + * + * @param options Source and optional child session id for the fork. + * @returns The created live child session. */ fork(options: ForkSessionOptions): Session { if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 064cc11fce..d7095633c9 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and `journal_mode = WAL`. The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). +The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). ## Contract semantics over rows @@ -21,6 +21,7 @@ The repo targets Node ≥ 24 (the root `engines` field), which includes the stab ```ts interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 412c3b58fc..30387b4837 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -28,7 +28,7 @@ import { } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { - openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, + type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' export { SCHEMA_VERSION } from './schema.ts' @@ -54,6 +54,13 @@ export interface Config { * dirs) on construction. */ path: string + /** + * SQLite `journal_mode` pragma. `wal` (the default) is the recorded + * durability model; pick a rollback-journal mode (`delete`/`truncate`/ + * `persist`) on filesystems where WAL's shared-memory files do not work + * (network mounts). See {@link JournalMode}. + */ + journalMode?: JournalMode } /** @@ -66,6 +73,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers static Config: z<Config> = z.object({ path: z.string().required(), + journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), }) /** @@ -83,18 +91,19 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers super(ctx) // Open the database asynchronously (the parent directory may need creating); // every hook awaits `ready` first. Opening synchronously would force a sync - // mkdir and block plugin apply. - this.ready = this.openDb(config.path) + // mkdir and block plugin apply. schemastery (static Config) has already + // filled `journalMode`; the cast records that runtime fact. + this.ready = this.openDb(config.path, (config as Required<Config>).journalMode) this.coordinator = new PersistenceCoordinator<number>(this.ctx, this) } - private async openDb(path: string): Promise<void> { + private async openDb(path: string, journalMode: JournalMode): Promise<void> { if (path !== ':memory:') { const abs = resolve(path) await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) - this.db = openDatabase(abs) + this.db = openDatabase(abs, journalMode) } else { - this.db = openDatabase(path) + this.db = openDatabase(path, journalMode) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index d8db0e087b..2ed6f5853c 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -45,10 +45,21 @@ export interface EventRow { surface_op: string | null } +/** + * Journal modes the backend will run under. `wal` is the default and the + * durability model the persistence ADR records; the rollback-journal modes + * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's + * shared-memory files do not work (network mounts). `memory`/`off` are + * excluded: dropping journal durability silently contradicts what this + * backend promises. + */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' + /** * Open the database at `path` and apply the schema + pragmas. `foreign_keys` - * makes `ON DELETE CASCADE` drop a session's events with its row; `journal_mode - * = WAL` matches the durability model the ADR records (the row shape maps 1:1 + * makes `ON DELETE CASCADE` drop a session's events with its row; the + * `journal_mode` pragma is set from the plugin's `journalMode` config (`wal` + * default — the durability model the ADR records; the row shape maps 1:1 * onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL). * * The table-layout version is persisted in SQLite's `PRAGMA user_version` and @@ -66,10 +77,12 @@ export interface EventRow { * makes the version check reject both sibling v3 databases instead of opening * one against columns it does not have. */ -export function openDatabase(path: string): DatabaseSync { +export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) db.exec('PRAGMA foreign_keys = ON') - db.exec('PRAGMA journal_mode = WAL') + // journalMode is a closed in-code union (validated by the plugin Config), not + // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params). + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 7138718aa5..a6eda72685 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -53,7 +54,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => { // A row past the committed region whose `data` does not parse: scanRows // bounds the preserved prefix at it and returns its seq as tornFrom, which // the backend surfaces to the coordinator as the tornMarker to delete from. - const db = openDatabase(path) + const db = openDatabase(path, 'wal') const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?') .get(id) as { n: number }).n db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') @@ -192,7 +193,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5 await b1.dispose() // Hand-write an interrupted turn (turn/start seq 6, no turn/end). - const db = openDatabase(path) + const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) db.close() @@ -204,7 +205,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(loaded.events.at(-1)!.type).toBe('turn/end') // load() is mutating: the synthetic turn/end MUST be on disk so the stored log // is balanced and the cursor is truthful (contract: load closes, not defers). - const probe = openDatabase(path) + const probe = openDatabase(path, 'wal') const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[] probe.close() expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) @@ -237,21 +238,21 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => { const path = await freshDbPath() - openDatabase(path).close() // stamp user_version = SCHEMA_VERSION + openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION // Bump user_version past what this build supports. - const dbNewer = openDatabase(path) + const dbNewer = openDatabase(path, 'wal') dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`) dbNewer.close() - expect(() => openDatabase(path)).toThrow(/incompatible with this build/) + expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) // A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected — // we do not migrate (unreleased software, no backward-compat). const olderPath = await freshDbPath() - openDatabase(olderPath).close() - const dbOlder = openDatabase(olderPath) + openDatabase(olderPath, 'wal').close() + const dbOlder = openDatabase(olderPath, 'wal') dbOlder.exec('PRAGMA user_version = 1') dbOlder.close() - expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/) + expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { @@ -261,11 +262,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // of this build's columns, so it MUST be rejected, not opened. Stamp a v3 // database and confirm the version check refuses it. const path = await freshDbPath() - openDatabase(path).close() // creates + stamps user_version = SCHEMA_VERSION (4) - const db = openDatabase(path) + openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) + const db = openDatabase(path, 'wal') db.exec('PRAGMA user_version = 3') db.close() - expect(() => openDatabase(path)).toThrow(/schema version 3, incompatible with this build/) + expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/) }) it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => { @@ -281,7 +282,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // unloadable; a torn tail must be discarded. scanRows finds the last // turn/end on the seq+type columns (never parsing tail `data`), so the // unparsable row after it bounds the preserved prefix and is deleted by load. - const db = openDatabase(path) + const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', '{not valid json') db.close() @@ -370,6 +371,29 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b2.dispose() }) + it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => { + // :memory: databases always report journal_mode=memory, so probe file DBs. + const walPath = await freshDbPath() + const bWal = await backend(walPath) + await bWal.ctx.sessionPersistence.create(meta('jm-wal')) + expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal') + await bWal.dispose() + + const deletePath = await freshDbPath() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: deletePath, journalMode: 'delete' }) + await ctx.sessionPersistence.create(meta('jm-delete')) + // Probe through a second connection: journal_mode=delete is a per-database + // property only insofar as no WAL files exist — assert the world, not the + // backend's self-report (no -wal sidecar after writes in delete mode). + const db = openDatabase(deletePath, 'delete') + expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete') + db.close() + expect(existsSync(`${deletePath}-wal`)).toBe(false) + await fiber.dispose() + }) + it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => { const path = await freshDbPath() // Instance 1 materializes a session and disposes. diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index f28bc06d5b..5e1af6e563 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -105,6 +105,7 @@ export abstract class SessionPersistence extends Service { * until the first {@link append} (lazy materialization), in which case a * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. */ abstract create(meta: SessionHeader): Promise<void> @@ -114,6 +115,8 @@ export abstract class SessionPersistence extends Service { * contracts: the first event's `seq` MUST equal the stored next-seq (after * `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void> @@ -138,10 +141,16 @@ export abstract class SessionPersistence extends Service { * COMMITTED region (at or before the last real `turn/end`) makes the session * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for * the crash-recovery contract. + * @param id - the persisted session to reload. + * @returns the header plus the event log, ending on a balanced `turn/end` — + * immediately usable as a session seed. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> - /** Lightweight listing from metadata, without a full-log parse. */ + /** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise<SessionHeader[]> } diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 03990a7369..81bf886067 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -25,6 +25,8 @@ Unlike the in-process backends, the child does NOT share this cordis context — | `cwd` | string | parent cwd | Working directory for the child process and its ACP session. | | `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | | `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | +| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. | +| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. | ```yaml - id: subagent-acp diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 037d32889e..b2ddd9ab2d 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -21,7 +21,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts' +import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' export const inject = ['subagents'] @@ -52,6 +52,14 @@ export interface Config { * ambient secrets do not leak implicitly. */ env: Record<string, string> + /** + * Grace period (ms) for the child's EOF-driven quiesce on dispose — its + * window to flush persistence and tear down its own nested subprocesses + * before the parent escalates to a signal. + */ + disposeEofGraceMs?: number + /** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */ + disposeGraceMs?: number } export const Config: z<Config> = z.object({ @@ -61,8 +69,20 @@ export const Config: z<Config> = z.object({ cwd: z.string(), permission: z.union(['allow', 'reject'] as const).default('reject'), env: z.dict(z.string()).default({}), + disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) +/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`subagent-acp: ${name} must be a positive finite number`) + } +} + +/** The shape after schemastery applied the defaults (cwd has none). */ +type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'> + /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects @@ -70,8 +90,10 @@ export const Config: z<Config> = z.object({ */ class AcpProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. + readonly inheritsParentContext = false - constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {} + constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} start(request: SubagentStartRequest) { const spec: AcpRunSpec = { @@ -80,6 +102,8 @@ class AcpProvider implements SubagentProvider { cwd: this.config.cwd ?? process.cwd(), permission: this.config.permission, env: this.config.env, + disposeEofGraceMs: this.config.disposeEofGraceMs, + disposeGraceMs: this.config.disposeGraceMs, onError: (error, stopReason) => { // The seam forbids `result` rejecting, so a child-level failure is // flattened to a stop reason — preserve it here rather than losing it. @@ -91,5 +115,9 @@ class AcpProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config)) + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) + ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved)) } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 06f7a9ece8..74291b7e65 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -73,16 +73,16 @@ export interface AcpRunSpec { /** * Grace period (ms) for the child's EOF-driven quiesce in * {@link SubagentRun.dispose} — the window to flush persistence and tear down - * its OWN nested subprocesses before the parent escalates to a signal. Defaults - * to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value. + * its OWN nested subprocesses before the parent escalates to a signal. The + * plugin fills this from its `disposeEofGraceMs` config. */ - disposeEofGraceMs?: number + disposeEofGraceMs: number /** * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in - * {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS}; - * a test injects a small value to exercise the escalation without a long wait. + * {@link SubagentRun.dispose}. The plugin fills this from its + * `disposeGraceMs` config. */ - disposeGraceMs?: number + disposeGraceMs: number /** * Sink for a child-level failure that the run flattened into a stop reason * (the seam contract forbids `result` rejecting). The driver calls this with @@ -94,19 +94,20 @@ export interface AcpRunSpec { } /** - * Default grace for the child's EOF-driven quiesce on dispose — the window for it - * to flush persistence and tear down its OWN nested subprocesses (which may run - * their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a - * signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative - * child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a - * bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs - * MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off - * exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, - * so this is a standalone generous default, NOT derived from any child's internals. + * Default grace for the child's EOF-driven quiesce on dispose (the + * `disposeEofGraceMs` config) — the window for it to flush persistence and tear + * down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL` + * escalation) before the parent escalates to a signal. Deliberately LARGER than + * {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself + * waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s + * SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single + * signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it + * reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is + * a standalone generous default, NOT derived from any child's internals. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */ +/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** @@ -372,8 +373,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // Reach quiescence, not merely request it (dispose must AWAIT the child // actually stopping). If the child is already gone, nothing to do. if (child.exitCode !== null || child.signalCode !== null) return - const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS - const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS + const eofGraceMs = spec.disposeEofGraceMs + const graceMs = spec.disposeGraceMs // 1. Graceful: end the ACP request stream (stdin EOF) and let the child // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal // session — it tears down via the server bridge's connection-close path diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 9819320ec5..3eb12fac38 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, buildChildEnv, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -171,7 +171,7 @@ describe('dsh-subagent-acp', () => { const run = startAcpRun( { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }, // `touch <sentinel>` — runs only if the process is actually spawned. - { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} }, + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, ) const result = await run.result expect(result.stopReason).toBe('aborted') @@ -390,7 +390,7 @@ describe('dsh-subagent-acp', () => { // absent-sink branch). const run = startAcpRun( { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, - { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, ) const result = await run.result // The seam contract: a child-level failure resolves error, never rejects. @@ -398,6 +398,47 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) + it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => { + // Same trap scenario as the direct startAcpRun escalation test, but the + // graces arrive via the PLUGIN CONFIG through the registered provider — so a + // regression that stops threading config into AcpRunSpec (falling back to + // the 6s/3s defaults) blows past the 4000ms bound and fails loud. + const tmp = mkdtempSync(join(tmpdir(), 'acp-cfg-trap-')) + const ready = join(tmp, 'trap-armed') + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + permission: 'reject', + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + disposeEofGraceMs: 150, + disposeGraceMs: 150, + }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }), + ])).resolves.toBeUndefined() + await ctx.fiber.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('rejects a non-positive dispose grace at load', async () => { + for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) + .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) + await ctx.fiber.dispose() + } + }) + it('resolves error via the provider (real load path) when the command does not exist', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -428,6 +469,8 @@ describe('dsh-subagent-acp', () => { cwd: process.cwd(), permission: 'reject', env: {}, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, }, ) diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 02c1811d82..b6d0c10e44 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -62,6 +62,8 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] { */ class ForkProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + // Context contract: a forked child IS seeded with the parent's completed-turn prefix. + readonly inheritsParentContext = true constructor(readonly name: string, private readonly ctx: Context) {} diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 4b8d2d4c99..1107926aa2 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -107,9 +107,9 @@ export function startInProcessRun( const seedLength = options.seed?.length ?? 0 const parentHeader = request.parent.session.header // Inherit the parent's model by default (a child with no model cannot run); - // an explicit `request.agentOptions.model` overrides it. The parent's - // systemPrompt is NOT inherited — a fresh child is a clean specialist unless - // the caller supplies one. + // an explicit `request.agentOptions.model` overrides it. The persona needs + // no inheritance: the deployment persona is a context-wide prompt section, + // so parent and child render the same one. const agentOptions: AgentOptions = { ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, ...request.agentOptions, diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 2ea082e20a..e6cf5039a7 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -39,6 +39,8 @@ export const Config: z<Config> = z.object({ */ class SpawnProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + // Context contract: a spawned child starts fresh — it never sees the parent conversation. + readonly inheritsParentContext = false constructor(readonly name: string, private readonly ctx: Context) {} diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index ff551cfc3f..b3e9d4ec24 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -23,7 +23,10 @@ export async function spawnHarness(workdir: string): Promise<Context> { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + // The deployment persona is context-wide (parent AND spawned children + // render it), so it stays neutral for both roles; the delegation nudge + // lives in the e2e's user prompt and the subagent tool's own description. + await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 8179027976..daa032199e 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -29,11 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { - model: 'deepseek-v4-flash', - systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — ' - + 'give it a complete, standalone instruction. Report only when done.', - }) + const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 57862ca8ab..8bab4c61c9 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -28,10 +28,14 @@ Unlike the bash seam (one executor per context, second load throws), **multiple - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. +Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. + ## Run lifecycle `provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. +The service also announces provider lifecycle: `subagent/provider-added` (the live provider) fires after a registration and `subagent/provider-removed` (the name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. + ## Scope (first cut) The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 356ad60a00..91230b7ff4 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -20,11 +20,21 @@ * semantics are deferred to a future redesign that unifies long-running-tool * handling across subagents and bash. * + * The `subagent/start` / `subagent/end` lifecycle events carry an OBSERVE-ONLY + * payload; `subagent/end` additionally carries the child's `lastAssistantMessage` + * — see `docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md`. + * FIXME(subagent-continuation): a control-flow `subagent/end` (an awaited + * waterfall returning a stop/continue decision, like the other interception + * seams) would require reshaping this emit into a waterfall, awaiting listeners + * before settling, and a `resume` capability on the in-process provider — part + * of the deferred background/steering redesign, NOT this observe-only cut. + * * @module @deepseek-ai/dsh-subagent */ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, @@ -50,16 +60,39 @@ declare module 'cordis' { } interface Events { + /** + * A provider became resolvable in the {@link SubagentService} registry. + * Consumers that derive state from a named provider (e.g. the model-facing + * tool wording in `dsh-tool-subagent`) react HERE instead of assuming load + * order — the cordis Loader starts sibling plugins concurrently, so + * "listed earlier in cordis.yml" does not mean "registered earlier". + * @param provider - the provider that just registered, live in the registry. + * @mode emit + */ + 'subagent/provider-added'(provider: SubagentProvider): void + /** + * A provider left the registry (its plugin's fiber was disposed — an + * unload or an HMR reload). Consumers holding provider-derived state drop + * it here; a reload re-fires `subagent/provider-added` with the fresh + * provider. Delivered with per-listener containment: a throwing + * subscriber is logged, never starves later subscribers, and never + * disrupts the provider's teardown. + * @param name - the registry name that no longer resolves. + * @mode emit + */ + 'subagent/provider-removed'(name: string): void /** * A subagent run started — emitted after the provider is resolved and its * capabilities validated, as the child run begins. Paired with * {@link Events['subagent/end']}. + * @param info - which provider started which child agent. * @mode emit */ 'subagent/start'(info: SubagentRunInfo): void /** * A subagent run settled — emitted when {@link SubagentRun.result} * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * @param info - the run identity plus stop reason and final output. * @mode emit */ 'subagent/end'(info: SubagentRunEndInfo): void @@ -82,6 +115,14 @@ export interface SubagentRunEndInfo { id: AgentId /** The terminal stop reason. */ stopReason: SubagentResult['stopReason'] + /** + * The child's final assistant output ({@link SubagentResult.output}), carried + * onto the end event so an observer sees WHAT the subagent produced without + * holding the run. Absent when the run rejected at the infrastructure level + * (no {@link SubagentResult} was produced — the seam only knows `stopReason: + * 'error'`). + */ + lastAssistantMessage?: ContentBlock[] } /** @@ -110,7 +151,11 @@ export class SubagentService extends Service { /** * Register a provider under its `provider.name`. Throws {@link SubagentError} * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed - * with the calling fiber (HMR-safe). + * with the calling fiber (HMR-safe). Emits `subagent/provider-added` after + * the registration and `subagent/provider-removed` on unregistration, so + * consumers can mirror provider lifecycle instead of assuming load order. + * @param provider - the provider; its `name` is the registry key. + * @returns the disposer that unregisters the provider. */ registerProvider(provider: SubagentProvider): () => void { const dispose = this.ctx.effect(function* (this: SubagentService) { @@ -118,21 +163,36 @@ export class SubagentService extends Service { throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') } this.providers.set(provider.name, provider) + // Yield the rollback BEFORE emitting `subagent/provider-added`: a + // throwing added-listener then unregisters the provider (and announces + // the removal) instead of leaking it into the registry. The removal + // announcement itself is contained PER LISTENER ({@link emitLifecycle}): + // it runs inside this disposer, where a propagating subscriber would + // disrupt the backend fiber's teardown and starve later mirrors. yield () => { this.providers.delete(provider.name) + this.emitLifecycle('subagent/provider-removed', provider.name) } + this.ctx.emit('subagent/provider-added', provider) }.bind(this), 'subagents.registerProvider()') // ctx.effect's disposer returns Promise<void>; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. return () => void dispose() } - /** Look up a registered provider by name (`undefined` if absent). */ + /** + * Look up a registered provider by name (`undefined` if absent). + * @param name - the provider name as registered. + * @returns the provider, or undefined when the name is unknown. + */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) } - /** The names of all registered providers (insertion order). */ + /** + * The names of all registered providers (insertion order). + * @returns the registered provider names. + */ list(): string[] { return [...this.providers.keys()] } @@ -144,6 +204,9 @@ export class SubagentService extends Service { * for the first unmet one — fail loud, before any child is created), then * delegates to {@link SubagentProvider.start} and emits `subagent/start` / * `subagent/end` around the run. + * @param name - the provider to run on. + * @param request - the child's prompt, capabilities, and options. + * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { const provider = this.providers.get(name) @@ -165,11 +228,33 @@ export class SubagentService extends Service { // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). Per-listener - // containment also keeps a thrown `subagent/end` listener from becoming an - // unhandled rejection on this detached `.then`. + // (the consumer still observes it via `run.result`). On the resolve path the + // child's final output rides on the event (lastAssistantMessage); on the + // reject path there is no SubagentResult, so only the stop reason is known. + // Per-listener containment also keeps a thrown `subagent/end` listener from + // becoming an unhandled rejection on this detached `.then`. void run.result.then( - (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, + (result) => { + // Deep-clone the output onto the event: this detached `.then` runs BEFORE + // the caller's own `await run.result` continuation, so handing listeners + // the SAME array reference the caller consumes would let a mutating + // `subagent/end` listener corrupt the caller's SubagentResult.output — + // breaking the observe-only contract. A snapshot makes the event a + // read-only view, not a shared handle. The clone is wrapped: it runs + // inside `onFulfilled`, OUTSIDE emitLifecycle's per-listener containment, + // so an uncloneable value (a future non-serializable content-block type, + // or a contract-violating result with no `output`) would otherwise become + // an unhandled rejection on this detached `.then`. On clone failure, log + // and emit the event WITHOUT lastAssistantMessage rather than dropping the + // whole `subagent/end`. + let lastAssistantMessage: SubagentResult['output'] | undefined + try { + lastAssistantMessage = structuredClone(result.output) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) + } + this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }) + }, () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, ) return run @@ -185,10 +270,22 @@ export class SubagentService extends Service { * on the first throw — so this resolves the listener callbacks via * `ctx.events.dispatch` and contains each call, the same guarantee * `BashExecutor.notifyTaskDone` gives its own listener set. + * + * `subagent/provider-removed` routes through here too: it fires inside the + * provider registration's DISPOSER, where a propagating listener would + * disrupt the backend fiber's teardown (dispose must reach quiescence) and a + * starved later listener would leave a mirror consumer (`dsh-tool-subagent`) + * holding a tool for a provider that no longer exists. `subagent/provider-added` + * deliberately does NOT: it fires at registration time, where a throwing + * listener unwinds the yielded rollback — the same fail-loud register-time + * semantics as the system-prompt registries. */ + private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo): void + private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo): void + private emitLifecycle(name: 'subagent/provider-removed', info: string): void private emitLifecycle( - name: 'subagent/start' | 'subagent/end', - info: SubagentRunInfo | SubagentRunEndInfo, + name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed', + info: SubagentRunInfo | SubagentRunEndInfo | string, ): void { for (const callback of this.ctx.events.dispatch('emit', [name, info])) { try { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb60d5667c..ef76a96e5d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -163,6 +163,16 @@ export interface SubagentProvider { readonly name: string /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities + /** + * The provider's context contract: `true` when a child SEES the parent + * conversation (fork — the child is seeded with the parent's completed-turn + * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, + * not a start-time capability: the service validates nothing against it — + * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool + * wording from it, so a tool bound to a fork provider stops telling the + * model the child "does not see this conversation". + */ + readonly inheritsParentContext: boolean /** * Start a child run. The service has already validated that every requested * start-time capability is supported, so an implementation may assume e.g. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6876c6cd80..e49b4c12e0 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -22,6 +22,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, /** A scripted provider whose run settles immediately with a fixed result. */ class StubProvider implements SubagentProvider { startCount = 0 + readonly inheritsParentContext = false constructor( readonly name: string, readonly capabilities: SubagentCapabilities = ALL_CAPS, @@ -44,6 +45,59 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta } describe('SubagentService', () => { + it('announces provider lifecycle: added on register, removed on dispose', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const added: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/provider-removed', name => void removed.push(name)) + + const dispose = ctx.subagents.registerProvider(new StubProvider('alpha')) + expect(added).toEqual(['alpha']) + expect(removed).toEqual([]) + + dispose() + expect(removed).toEqual(['alpha']) + }) + + it('rolls back the registration when a provider-added listener throws', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let threw = false + const off = ctx.on('subagent/provider-added', () => { + if (!threw) { threw = true; throw new Error('boom added listener') } + }) + + expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener') + expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked + + off() + ctx.subagents.registerProvider(new StubProvider('alpha')) + expect(ctx.subagents.getProvider('alpha')).toBeDefined() + }) + + it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => { + // provider-removed fires inside the registration's DISPOSER, so a + // propagating listener would disrupt the backend's teardown; and cordis + // emit halts on the first throw, so an uncontained one would starve every + // mirror registered after it (a stale model-facing tool). Both are + // prevented by per-listener containment. + const ctx = new Context() + await ctx.plugin(SubagentService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn + ctx.on('subagent/provider-removed', () => { throw new Error('boom removed listener') }) + const heard: string[] = [] + ctx.on('subagent/provider-removed', name => void heard.push(name)) + + const dispose = ctx.subagents.registerProvider(new StubProvider('alpha')) + expect(() => { dispose() }).not.toThrow() + expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran + expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence + expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true) + }) + it('registers a provider and starts a run on it by name', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -173,6 +227,122 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('carries lastAssistantMessage (the child output) onto the end event', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider( + 'enriched', + ALL_CAPS, + { output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' }, + )) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('enriched', baseRequest()) + expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id })) + + await run.result + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'enriched', + id: run.id, + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'the child answer' }], + })) + }) + + it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => { + // The subagent/end emit fires from a detached `.then` registered before + // start() returns — i.e. BEFORE the caller's own `await run.result` + // continuation. If the event shared the result.output reference, a mutating + // listener would change the SubagentResult the caller consumes. The service + // deep-clones output onto the event, so the listener mutates only its copy. + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider( + 'clone', + ALL_CAPS, + { output: [{ type: 'text', text: 'original' }], stopReason: 'completed' }, + )) + + ctx.on('subagent/end', (info) => { + // A hostile/buggy listener reaches in and mutates the event's array. + const blocks = info.lastAssistantMessage + if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED' + blocks?.push({ type: 'text', text: 'injected' }) + }) + + const run = ctx.subagents.start('clone', baseRequest()) + const result = await run.result + await Promise.resolve() // let the detached settle hook (and its listener) run + // The caller's result.output is untouched by the listener's mutation. + expect(result.output).toEqual([{ type: 'text', text: 'original' }]) + }) + + it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'rej', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rej', baseRequest()) + await run.result.catch(() => {}) + await Promise.resolve() + + const endInfo = ended.mock.calls[0]![0] as Record<string, unknown> + expect(endInfo.stopReason).toBe('error') + expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject + }) + + it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => { + // The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener + // containment. An uncloneable output (here a content block carrying a + // function) would otherwise throw and become an unhandled rejection on the + // detached `.then`. The handler must instead log and emit the event WITHOUT + // lastAssistantMessage, still carrying the real stopReason. + const ctx = new Context() + await ctx.plugin(SubagentService) + const warn = vi.fn(); ctx.logger.warn = warn as never + // An output value structuredClone cannot handle (a function is uncloneable). + const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] + ctx.subagents.registerProvider({ + name: 'unclone', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('unclone-child'), + result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('unclone', baseRequest()) + await run.result + await Promise.resolve() + + const endInfo = ended.mock.calls[0]![0] as Record<string, unknown> + expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved + expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed + expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone')) + }) + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -182,6 +352,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider({ name: 'rejecter', capabilities: NO_CAPS, + inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), result: Promise.reject(new Error('infra fault')), diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 1bb48f29ff..6fe26d3083 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,11 +6,15 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. +## The description states the provider's context contract + +The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). + | Config key | Meaning | |---|---| | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | | `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | -| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | +| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 05490127ea..f48ef4345e 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -11,6 +11,15 @@ * — there is no provider/type parameter in the model-facing schema. The model * sees only `{ description, prompt }`. * + * The tool DESCRIPTION is derived from the bound provider's context contract + * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the + * standalone-prompt wording, an inheriting provider (fork) tells the model the + * child already sees the conversation's completed turns. The tool MIRRORS the + * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers + * when the provider is (or becomes) available and unregisters when the + * provider goes away — so no load-order requirement exists and an HMR reload + * of the backend re-derives the wording from the fresh provider. + * * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits * `run.result` inside a `try/finally` that always disposes the run, so the * owned child agent/session is torn down on every path (success, error, abort) @@ -26,7 +35,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent' export const inject = ['tools', 'subagents'] @@ -44,8 +53,10 @@ export interface Config { */ toolName?: string /** - * Default per-child agent options (model, system prompt) applied to every - * spawned child. Omitted fields fall back to the child loop's own defaults. + * Default per-child agent options (model) applied to every spawned child. + * Omitted fields fall back to the child loop's own defaults. There is no + * per-child persona: the deployment persona (the system-prompt plugin's + * `persona` config) is a context-wide section every agent shares. */ agentOptions?: AgentOptions } @@ -55,7 +66,6 @@ export const Config: z<Config> = z.object({ toolName: z.string().default('subagent'), agentOptions: z.object({ model: z.string(), - systemPrompt: z.string(), }), }) @@ -92,70 +102,140 @@ function stopReasonError(result: SubagentResult): string | undefined { } } -export function apply(ctx: Context, config: Config): void { - ctx.tools.register(defineTool({ - name: config.toolName ?? 'subagent', +/** + * Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}). + * A fresh child needs a standalone prompt; a forked child already sees the + * conversation's completed turns — telling the model to restate everything + * (or, worse, that the child "does not see this conversation") would be false + * for a fork. Exported for tests. + * @param inherits - the bound provider's context contract. + * @returns the tool `description` and the `prompt` parameter description. + */ +export function providerWording(inherits: boolean): { description: string; promptDescription: string } { + if (inherits) { + return { + description: + 'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all ' + + 'completed turns so far (it does not see the current in-flight turn), returning only its final ' + + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, ' + + 'a review, a continuation — without consuming this conversation\'s context for the work itself. ' + + 'You receive only its final answer, not its intermediate steps.', + promptDescription: + 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them ' + + 'freely and state only what is new.', + } + } + return { description: 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) ' + 'and return its final result. Use this to offload focused, independent work — research, a scoped ' + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent ' + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a ' + 'complete, standalone prompt: it does not see this conversation.', - parameters: { - description: { - type: 'string', - required: true, - description: 'A short (3-5 word) description of the delegated task, for display.', - }, - prompt: { - type: 'string', - required: true, - description: 'The complete, self-contained task for the subagent. It does not share this ' - + 'conversation\'s context, so include everything it needs.', - }, - }, - async execute(args, exec): Promise<ContentBlock[]> { - const parent = exec.agent - if (!parent) { - // The loop sets `exec.agent` for every model-driven call; its absence - // means a non-agent caller invoked the tool directly, which has no - // parent to attribute the child to. Fail loud rather than guess. - throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') - } - - const request: SubagentStartRequest = { - prompt: [{ type: 'text', text: args.prompt }], - parent, - ...exec.signal ? { signal: exec.signal } : {}, - ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, - } - - const run: SubagentRun = ctx.subagents.start(config.provider, request) - - // Bridge the tool's abort signal to the run: if the parent step is - // aborted while the child is in flight, cancel the child too. - const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before this - // line, so a step cancelled before the tool ran would never reach the - // child. Cancel explicitly in that case — the bridge must honor an - // already-aborted signal, not lean on each provider re-checking it. - if (exec.signal?.aborted) run.cancel('parent step aborted') - - try { - const result = await run.result - const error = stopReasonError(result) - if (error !== undefined) { - // Map a non-clean finish to an isError result (the registry turns a - // throw into an isError). Report the reason, not partial output. - throw new Error(error) - } - return [{ type: 'text', text: outputText(result.output) }] - } finally { - exec.signal?.removeEventListener('abort', onAbort) - // Always reach child quiescence — never leak a live idle child/session. - await run.dispose() - } - }, - })) + promptDescription: + 'The complete, self-contained task for the subagent. It does not share this ' + + 'conversation\'s context, so include everything it needs.', + } +} + +export function apply(ctx: Context, config: Config): void { + // The tool MIRRORS its provider's lifecycle instead of assuming load order: + // the cordis Loader starts sibling entries concurrently, so "backend listed + // first in cordis.yml" does not guarantee "provider registered first", and + // an HMR reload of the backend replaces the provider while this fiber stays + // loaded. Register the tool when the bound provider is (or becomes) + // available — deriving the wording from THAT provider — and unregister it + // when the provider goes away, so the description can never outlive or + // predate the provider it describes. + let disposeTool: (() => void) | undefined + const mount = (provider: SubagentProvider): void => { + const wording = providerWording(provider.inheritsParentContext) + disposeTool = ctx.tools.register(defineTool({ + name: config.toolName ?? 'subagent', + description: wording.description, + parameters: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: wording.promptDescription, + }, + }, + async execute(args, exec): Promise<ContentBlock[]> { + const parent = exec.agent + if (!parent) { + // The loop sets `exec.agent` for every model-driven call; its absence + // means a non-agent caller invoked the tool directly, which has no + // parent to attribute the child to. Fail loud rather than guess. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') + } + + const request: SubagentStartRequest = { + prompt: [{ type: 'text', text: args.prompt }], + parent, + ...exec.signal ? { signal: exec.signal } : {}, + ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + } + + const run: SubagentRun = ctx.subagents.start(config.provider, request) + + // Bridge the tool's abort signal to the run: if the parent step is + // aborted while the child is in flight, cancel the child too. + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + // `addEventListener` does NOT fire for a signal already aborted before this + // line, so a step cancelled before the tool ran would never reach the + // child. Cancel explicitly in that case — the bridge must honor an + // already-aborted signal, not lean on each provider re-checking it. + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const result = await run.result + const error = stopReasonError(result) + if (error !== undefined) { + // Map a non-clean finish to an isError result (the registry turns a + // throw into an isError). Report the reason, not partial output. + throw new Error(error) + } + return [{ type: 'text', text: outputText(result.output) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + // Always reach child quiescence — never leak a live idle child/session. + await run.dispose() + } + }, + })) + } + + // Listeners first, then the presence check: both run synchronously, so no + // registration can slip between them; the `disposeTool === undefined` guard + // makes a same-tick added-event after a successful mount a no-op. + // TODO(subagent-dup-toolname): two WAITING fibers configured with the same + // toolName collide only when their provider finally arrives — the duplicate + // tool-name throw then propagates through `subagent/provider-added` and + // rolls back the PROVIDER registration, so an invalid config blasts the + // backend's fiber instead of the misconfigured tool's. Config-time detection + // would need a cross-fiber registry of intended tool names; revisit if a + // real deployment ever hits it. + ctx.on('subagent/provider-added', (provider) => { + if (provider.name === config.provider && disposeTool === undefined) mount(provider) + }) + ctx.on('subagent/provider-removed', (name) => { + if (name !== config.provider || disposeTool === undefined) return + disposeTool() + disposeTool = undefined + }) + const present = ctx.subagents.getProvider(config.provider) + if (present !== undefined) { + mount(present) + } else { + // Not an error: the backend's fiber may simply activate after this one. + // The tool appears the moment the provider registers; a typo'd provider + // name shows up as this note plus a tool that never materializes. + ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) + } } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index dda4e7c3d0..611069ef76 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -112,6 +112,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'weird', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('weird-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), @@ -137,6 +138,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'capture', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: (request) => { seen = request return { @@ -147,10 +149,10 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' } }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) + expect(seen?.agentOptions).toEqual({ model: 'child-model' }) }) it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { @@ -166,6 +168,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'bare', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: (request) => { seen = request return { @@ -192,14 +195,96 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toContain('requires a calling agent') }) - it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here ' - + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => { - // Bind the tool to a provider name that is not registered: the service throws - // NO_PROVIDER, the registry turns it into an isError result. - const ctx = await setup({ provider: 'does-not-exist' }) + it('registers when the provider appears LATER — no load-order requirement (Loader starts siblings concurrently)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + // Tool first: no provider yet — the tool must be absent, not broken. + // Direct apply (schema bypass): also covers the waiting-note's default + // toolName fallback, which validated config pre-fills. + tool.apply(ctx, { provider: 'mock' }) + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + // Backend arrives (as a delayed sibling fiber would): the tool appears. + await ctx.plugin(mock, { name: 'mock', reply: 'late but fine' }) + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('no subagent provider') + expect(text(result)).toBe('late but fine') + }) + + it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false) + await ctx.plugin(tool, { provider: 'mock' }) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') + + // Backend unloads (HMR shape): the tool must not outlive its provider. + await backend.dispose() + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + + // Backend reloads with a DIFFERENT contract: the wording is re-derived + // from the fresh provider, not served stale from the first mount. + await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation') + }) + + it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + + // Arm 1: a mounted tool dies with its plugin fiber; the provider survives. + await ctx.plugin(mock, { name: 'mock' }) + const mounted = await ctx.plugin(tool, { provider: 'mock' }) + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + await mounted.dispose() + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + expect(ctx.subagents.getProvider('mock')).toBeDefined() + + // Arm 2: a fiber disposed while WAITING must not react to the provider + // arriving later — a surviving listener would re-register a tool that no + // live plugin owns (the zombie mount). + const waiting = await ctx.plugin(tool, { provider: 'later', toolName: 'subagent_later' }) + await waiting.dispose() + await ctx.plugin(mock, { name: 'later' }) + expect(ctx.tools.schemas().some(s => s.name === 'subagent_later')).toBe(false) + }) + + it('ignores lifecycle events for OTHER providers', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'mock' }) + await ctx.plugin(tool, { provider: 'mock' }) + // An unrelated provider registering (added-event with another name) and + // unregistering (removed-event with another name) must not touch the tool. + const other = await ctx.plugin(mock, { name: 'other', inheritsParentContext: true }) + expect(ctx.tools.schemas().filter(s => s.name === 'subagent')).toHaveLength(1) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') + await other.dispose() + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + }) + + it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('does not see this conversation') + const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties + expect(props['prompt']!.description).toContain('include everything it needs') + }) + + it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => { + const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('INHERITS this conversation') + expect(schema.description).not.toContain('does not see this conversation') + const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties + expect(props['prompt']!.description).toContain('completed turns') }) it('disposes the run on the success path (no leaked child)', async () => { @@ -213,6 +298,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), @@ -235,6 +321,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), result: Promise.resolve({ output: [], stopReason: 'error' as const }), @@ -258,6 +345,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) @@ -276,6 +364,14 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + // Abort AFTER the tool body has had a chance to register its abort listener + // (ctx.tools.execute now awaits the tools/pre-execute waterfall before the + // body runs, so the listener is not registered synchronously). A few + // microtask turns let execute() reach `addEventListener('abort')`, so this + // exercises the LIVE onAbort bridge — distinct from the already-aborted + // sync path the next test covers. + await Promise.resolve() + await Promise.resolve() controller.abort() const result = await pending expect(cancelled).toHaveBeenCalledTimes(1) @@ -296,6 +392,7 @@ describe('dsh-tool-subagent', () => { ctx.subagents.registerProvider({ name: 'spy', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) diff --git a/packages/support/README.md b/packages/support/README.md index 942734fc1e..233a32d77f 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,8 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| | `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | -| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index a08ccf01a7..b26170d48e 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -34,7 +34,7 @@ Session log (per session): - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. -- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown `tools/execute` waterfall ends the step with no `tool/result`, which is legal). +- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal). Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 08fbf49b57..08d4365f4b 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -256,8 +256,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { case 'tool/result': { requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) // A result needs a prior matching call in the same step. (The converse - // does NOT hold: a call may have no result — a throwing tools/execute - // waterfall ends the step with no tool/result, which is legal.) + // does NOT hold: a call may have no result — a throwing tool-execution + // pipeline step ends the turn with no tool/result, which is legal.) const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) { throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 2dd8357cc7..87cc62d165 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -118,7 +118,7 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } } }, + { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ] diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index 305aea93c4..af169ed4c2 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -14,6 +14,7 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `reply` | `mock subagent reply` | The scripted child's final answer text. | | `stopReason` | `completed` | The stop reason `result` settles with. | | `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | +| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. | | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index c1a988fbfe..e2effe5b4b 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -37,12 +37,14 @@ const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: tru */ class MockSubagentProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean constructor( readonly name: string, private readonly config: Config, ) { this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } + this.inheritsParentContext = config.inheritsParentContext ?? false } start(request: SubagentStartRequest): SubagentRun { @@ -88,6 +90,12 @@ export interface Config { stopReason?: SubagentStopReason /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial<SubagentCapabilities> + /** + * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); + * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool + * wording in consumer tests. + */ + inheritsParentContext?: boolean /** * Structured value surfaced when a request carries an `outputSchema` and the * `outputSchema` capability is on (default: `{ reply }`). @@ -104,6 +112,7 @@ export const Config: z<Config> = z.object({ depthLimit: z.boolean(), toolFilter: z.boolean(), }), + inheritsParentContext: z.boolean(), structured: z.any(), }) diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md deleted file mode 100644 index b65fd4d8e1..0000000000 --- a/packages/support/ui-stdio/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @deepseek-ai/dsh-ui-stdio - -A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the `agent/*` event taxonomy plus the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. - -This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`. - -## Config - -| Key | Type | Default | Notes | -|---|---|---|---| -| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. | -| `agent` | string | `'main'` | Id of the agent that stdin **drives** (`send`/`steer`) and whose `agent/status` gates the EOF exit. Rendering is **not** scoped by it — see below. | - -```yaml -- id: ui-stdio - name: '@deepseek-ai/dsh-ui-stdio' - config: - welcome: 'coding-agent ready. Give it a coding task.' -``` - -## Rendering - -Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - -- `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on. -- `agent/turn-start` / `agent/turn-end` — a `[<agent> turn N]` header and a trailing `> ` prompt. -- `session/event` — `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`. - -## The I/O seam - -The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals. - -## Piped-stdin exit - -On stdin EOF the plugin exits the process, but carefully: - -- **No work submitted** (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed `running` here would hang forever. -- **Work submitted**: exit the next time the agent settles to `idle` *after* having been observed `running`. `agent.send()` does not synchronously flip status to `running`, so requiring an observed `running` first (`sawRunning`) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends. - -Disposal (HMR or fiber teardown) closes the readline interface, which also fires `close` — a `disposed` guard ensures teardown never calls `process.exit`. - -## Plugin export shape - -Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. diff --git a/packages/todo/README.md b/packages/todo/README.md index df258fab0c..3f427295c1 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio UI](../support/ui-stdio) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index fc6dc94860..b27cc65227 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio UI](../../support/ui-stdio) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 912e08f269..d2175f49b9 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -51,8 +51,8 @@ const DESCRIPTION = * `InferArgs` maps an `enum` string prop to plain `string`, so the compiler sees * `args.todos` as `{ content: string; status: string }[]`; the * `status as TodoItem['status']` narrowing records that registry guarantee - * rather than re-checking it (an unreachable re-check would be dead code — see - * AGENTS.md "don't validate scenarios that can't happen"). What remains is the + * rather than re-checking it (an unreachable re-check would be dead code the + * coverage gate would flag). What remains is the * value rules the DSL has no vocabulary for: non-empty unique content (stored * trimmed, so the persisted value matches the dedupe/length key), and at most * one `in_progress` task. @@ -118,6 +118,6 @@ export function apply(ctx: Context): void { text: `Updated todo list: ${count('pending')} pending, ${count('in_progress')} in progress, ${count('completed')} completed.`, }]) }, - presentCall: args => ({ title: 'Update todo list', kind: 'other', rawInput: args.todos }), + presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }), })) } diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 8b1c504840..86e8814633 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -135,7 +135,7 @@ describe('dsh-tool-todo', () => { const ctx = await setup() const def = ctx.tools.get('todo_write')! const todos = [{ content: 'a', status: 'pending' }] - expect(def.presentCall?.({ todos })).toEqual({ title: 'Update todo list', kind: 'other', rawInput: todos }) + expect(def.presentCall?.({ todos })).toEqual({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: todos }) }) it('unregisters the tool when its contributing fiber is disposed (HMR-safety)', async () => { diff --git a/packages/ui/README.md b/packages/ui/README.md index 659519407c..8b0293cf9e 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -7,7 +7,8 @@ Integrations that expose the agent to an external editor or client. These are ** | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | +| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. `stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 3403ef2126..4a114619a6 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -23,7 +23,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | 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 | +| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-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`). diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 72eb95b2f7..71f8fe0171 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -32,6 +32,7 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -41,6 +42,7 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 24e31f3251..1cbdff64d1 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -2,167 +2,45 @@ /** * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that * loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter - * and a bash executor), speaking ACP JSON-RPC on stdio. + * and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue — + * `.env` loading, the fail-loud Loader guards, snapshot-aware config + * resolution, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific + * lifecycle: * - * Owns the ACP-specific boot glue the example's `start.ts` once held: - * - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in - * snapshot REPLAY so a stray key can never trigger a live model call. - * - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given - * `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay - * tree: `llm-replay` in place of `llm-deepseek`). - * - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin - * when done, so dispose the context (flushing persistence) and exit cleanly. + * - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never + * trigger a live model call. + * - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling + * `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of + * `llm-deepseek`). + * - In a snapshot run the harness closes stdin when done, so dispose the + * context (flushing persistence) and exit cleanly. In a normal editor + * session stdin stays open for the connection's lifetime (the editor kills + * the process), so the EOF handler never fires. * * IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to - * STDERR only; the app plugin loads no stdout logger. A stray stdout write - * corrupts the protocol frames. + * STDERR only (the app plugin loads no stdout logger, and the shared guards + * write to stderr); a stray stdout write corrupts the protocol frames. * * Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`). * * @module @deepseek-ai/dsh-acp-agent/bin */ -import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -/** - * Resolve the config to boot, honoring snapshot REPLAY. Given the requested - * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in - * the SAME directory (the keyless replay tree). Other modes use the path as-is. - * Returns an absolute path resolved from the cwd. - */ -export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string { - const absolute = resolve(process.cwd(), configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} +const NAME = 'dsh-acp-agent' -/** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the - * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In - * REPLAY mode the caller skips this entirely — replay must never reach the - * network, so a present `.env` must not enable a live call. - */ -function loadEnv(): void { - try { - process.loadEnvFile(resolve(process.cwd(), '.env')) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} - -/** - * Make a load failure fail loud with a clear message on stderr. Covers the - * failure path the entry-tree check below cannot: when the include's - * `[Service.init]` throws (e.g. a config FILE missing in a real directory), the - * cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()` - * resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses - * `Promise.allSettled`, which swallows rejections). Node's default handler - * already exits non-zero on an unhandled rejection, so this does not change the - * exit code; it replaces the noisy stack dump with a single labelled line (on - * STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`. - * Install before `boot()`. - */ -export function installFailLoud(): void { - process.on('unhandledRejection', (err: unknown) => { - process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - process.exit(1) +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the + built-bin smoke */ +installFailLoud(NAME) +const snapshotMode = process.env['DSH_SNAPSHOT'] +if (snapshotMode !== 'replay') loadEnv(NAME) +const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode)) +if (snapshotMode !== undefined) { + process.stdin.on('end', () => { + void ctx.fiber.dispose().then(() => { process.exit(0) }) }) } - -/** - * After the tree settles, assert every loader entry actually started. This is - * the load-bearing guard against the SILENT-exit-0 bug: a plugin module that - * fails to IMPORT (e.g. a config path in a non-existent directory) is caught and - * only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no - * `fiber` and producing no rejection — so the process would otherwise exit 0. A - * started entry has a `fiber`; throw on any entry still missing one so `boot()` - * rejects. - * - * A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()` - * deliberately skips `init()` for it, so it settles without a fiber by design — - * a valid "plugin turned off" config, not a failed import. Exclude it. - */ -function assertEntriesLoaded(ctx: Context): void { - const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) - if (failed.length > 0) { - const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`) - } -} - -/** - * Boot the Loader against `absoluteConfigPath`. The include is handed the - * config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on - * `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to - * the cwd. `baseUrl` is still pinned to the config's directory so the config's - * OWN relative plugin/include paths resolve against it. Returns the root context - * once the whole tree has settled. - * - * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once - * the include ENTRY is registered, but the include then loads its child plugins - * asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP - * bridge is still mounting — the process would have no stdin handle attached yet - * and could exit 0 silently. Awaiting keeps the process alive until the bridge - * is up. - * - * `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses - * `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails - * to IMPORT leaves an entry with no fiber, caught here by - * {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS - * surfaces as an unhandled rejection caught by {@link installFailLoud} (installed - * by `main()` before this runs). Together any load failure exits non-zero. - * - * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are - * resolved by the cordis Loader's internal module loader, which is only active - * under `node --expose-internals`. The `demo:acp` script runs under tsx (whose - * tsconfig `paths` map resolves the workspace plugins instead), but a consumer - * running the built bin under plain node must pass `--expose-internals` so the - * Loader resolves the config's plugins from the config directory rather than - * relative to its own module. - */ -export async function boot(absoluteConfigPath: string): Promise<Context> { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - await ctx.plugin(Loader) - await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { path: pathToFileURL(absoluteConfigPath).href }, - }) - await ctx.loader.await() - assertEntriesLoaded(ctx) - return ctx -} - -/** - * Entry point. Installs the fail-loud guard, selects the config (snapshot-aware), - * loads `.env` outside replay, boots, and — in a snapshot run — disposes the - * context on stdin EOF so the session log is fully flushed before exit and the - * harness's `waitForExit` resolves. In a normal editor session stdin stays open - * for the connection's lifetime (the editor kills the process), so the EOF - * handler never fires. - */ -export async function main(argv: string[] = process.argv.slice(2)): Promise<void> { - installFailLoud() - const snapshotMode = process.env.DSH_SNAPSHOT - const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode) - if (snapshotMode !== 'replay') loadEnv() - const ctx = await boot(configPath) - if (snapshotMode !== undefined) { - process.stdin.on('end', () => { - void ctx.fiber.dispose().then(() => { process.exit(0) }) - }) - } -} - -/* v8 ignore start -- top-level CLI invocation; the testable core is - resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */ -await main() /* v8 ignore stop */ diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index c505e9bf41..3bd498dc63 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -39,35 +39,38 @@ 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`); + * App config: the swappable per-deployment values. `model` configures the + * agent template the ACP bridge creates each session's agent from (NOT a + * pre-created agent — ACP creates agents at `session/new`); `persona` is the + * deployment persona (forwarded to the system-prompt plugin); * `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 + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string } export const Config: z<Config> = z.object({ model: z.string().required(), - systemPrompt: z.string().required(), + persona: z.string(), 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. + * NO agents (its `agents` list defaults to `[]`) and carries the deployment + * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP + * bridge owns stdout for JSON-RPC and creates one agent per `session/new` + * from `model`. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { - ctx.plugin(agentCore) + ctx.plugin(agentCore, { + ...config.persona !== undefined ? { persona: config.persona } : {}, + }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) - ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt }) + ctx.plugin(acp, { model: config.model }) } diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 7a02837fca..87cf670107 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -24,7 +24,7 @@ async function mount(config: acpAgent.Config): Promise<Context> { 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' }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -40,7 +40,8 @@ describe('dsh-acp-agent composition', () => { // `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' }) + // No persona: covers the omitted-persona forwarding branch too. + acpAgent.apply(ctx, { model: 'mock' }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('sessionPersistence')).toBeDefined() await ctx.fiber.dispose() diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index 51c5d53c0b..d900c14152 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -40,7 +40,7 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js') const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', + 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent', ] diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index ffea8ec6f6..6cc211087c 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../acp" }, diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ad50383542..60363566b7 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes 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 DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/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`. @@ -15,9 +15,10 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | Key | Default | Meaning | |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | -| `systemPrompt` | — | Per-agent system prompt. | -| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. | -| `agentVersion` | `0.0.1` | Server version reported in `initialize`. | + +(No persona key: the deployment persona is `dsh-system-prompt`'s own `persona` config — a context-wide section, so ACP-created agents render it without the bridge carrying prompt text.) + +The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config. ## ACP method mapping @@ -28,7 +29,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | `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.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) | +| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | ## Multi-session @@ -42,22 +43,28 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader ## Tool-call presentation -How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.) +How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards: -The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. +- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along). +- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). +- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. + +`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. + +The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. ## Terminal card (capability-gated) -A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: +A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: -- `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. +- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `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). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card. +- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. 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 a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — 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) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). ## Settle-exactly-once -A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. +A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. ## Disposal & disconnect @@ -65,7 +72,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/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/pre-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 diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 429c862b27..d37c9b1eaf 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). | | `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. | | `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). | -| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. | +| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). | | `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. | ### 3b. `clientCapabilities` (consumed by the bridge) @@ -96,12 +96,12 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| -| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | +| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. | | `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | | `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | -| `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). | +| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). | | `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. | +| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. | | `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. | | `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. | @@ -147,7 +147,6 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 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 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. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 52080be092..cdc952558c 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp", - "description": "Agent Client Protocol (ACP) bridge: drive the DeepSeek Harness coding agent from an ACP editor over JSON-RPC stdio", + "description": "Agent Client Protocol (ACP) bridge: drive DeepSeek Harness SDK agents from an ACP editor over JSON-RPC stdio", "version": "0.0.1", "private": true, "type": "module", @@ -38,12 +38,15 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/ui/acp/snapshot-replay.md b/packages/ui/acp/snapshot-replay.md new file mode 100644 index 0000000000..4242f2406d --- /dev/null +++ b/packages/ui/acp/snapshot-replay.md @@ -0,0 +1,27 @@ +<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand. + Run `pnpm run gen-doc-graphs` to regenerate. --> + +# ACP Snapshot Replay + +This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove. + +```mermaid +sequenceDiagram + participant Recorder as Real API recording + participant Fixture as snapshot fixture + participant Workspace + participant Replay as llm-replay adapter + participant ACP as acp-agent subprocess + participant Golden as stdout golden + Recorder->>Fixture: session.jsonl + workspace inputs + Fixture->>Workspace: seed files and hook configs + Fixture->>Replay: recorded StreamChunk script + Replay->>ACP: deterministic <code>llm/stream</code> chunks + ACP->>Workspace: bash, fs, and hook side effects + ACP->>Golden: normalized sessionUpdate stream + Golden-->>ACP: diff must be empty +``` + +The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text. + +Maintenance mode: curated Mermaid sequence based on the snapshot test harness. diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 5f5a53f529..444e71545c 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -17,7 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr * Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum. * * The mapping is total over the kinds the loop actually produces today - * (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is + * (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`). + * `TurnEndReason` is * merge-extensible, so an unknown future kind falls through to `end_turn` — * the safest default (the turn DID end; we just lack a more specific wire * reason) — rather than throwing into the SDK, which would reject an unknown @@ -34,6 +35,10 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr * for any non-bridge caller / property test.) * - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a * cancellation from the client's perspective) + * - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit` + * hook before any step ran — ACP has no "rejected" reason, and a + * blocked prompt is, from the client's view, the prompt not being + * carried out; `cancelled` is the closest legal wire reason) */ export function turnEndToStopReason(reason: TurnEndReason): StopReason { switch (reason.kind) { @@ -45,6 +50,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'cancelled' case 'disposed': return 'cancelled' + case 'rejected': + return 'cancelled' case 'error': return 'end_turn' // Merge-extensible: an unknown future TurnEndReason kind still has to @@ -62,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { * client as message content. Today only `text` maps; `resource_link` is an * ACP prompt-only input rendered into text by {@link acpPromptToText}; * `reasoning` is surfaced via `agent_thought_chunk` - * streaming rather than as a message block, and `tool-call`/`tool-result`/ - * `image` are handled by the tool-call update path or not advertised. + * streaming rather than as a message block, and `tool-call`/`tool-result` + * are handled by the tool-call update path. */ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined { switch (block.type) { @@ -71,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | return { type: 'text', text: block.text } // reasoning → streamed as agent_thought_chunk, not a message block // tool-call / tool-result → the tool_call / tool_call_update path - // image → not advertised + // plugin-added block types → not surfaced default: return undefined } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 754b1750be..a819e607d0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -22,7 +22,7 @@ * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every * `session/event` and `agent/*` event is routed strictly to its owning session * record, so two sessions streaming at once never interleave their - * `session/update` notifications. The `tools/execute` permission gate is + * `session/update` notifications. The `tools/pre-execute` permission gate is * deferred — see the TODO(rfc010-permission-gate) note below. * * stdout is the protocol: this plugin must run in an example that loads NO @@ -36,7 +36,7 @@ import type { Context } from 'cordis' import { Readable, Writable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { isAbsolute, resolve as resolvePath } from 'node:path' +import { isAbsolute, relative as relativePath, resolve as resolvePath, sep as pathSep } from 'node:path' import Schema from 'schemastery' import { AgentSideConnection, @@ -62,12 +62,12 @@ import { type StopReason, } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' +import { assertNever, 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, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). import type {} from '@deepseek-ai/dsh-session-persistence' @@ -115,12 +115,6 @@ function sameWorkspaceCwd(left: string, right: string): boolean { export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** Per-agent system prompt. */ - systemPrompt?: string - /** Agent/server name reported to the client in `initialize`. */ - agentName?: string - /** Agent/server version reported to the client in `initialize`. */ - agentVersion?: string /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -133,9 +127,6 @@ export interface AcpConfig { export const Config: Schema<AcpConfig> = Schema.object({ model: Schema.string(), - systemPrompt: Schema.string(), - agentName: Schema.string().default('deepseek-harness-acp'), - agentVersion: Schema.string().default('0.0.1'), }) /** @@ -199,22 +190,16 @@ interface SessionRecord { } /** - * Drive the in-flight prompt's settle from the harness event stream. A turn - * can end three ways the bridge must all handle (AGENTS.md "honor cross-seam - * contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end` - * session event WITHOUT the agent event (a boundary emit threw inside the loop, - * which still appends `turn/end`); or the agent erroring/settling to idle. The - * first of these to fire settles the prompt; `settle` is then cleared so the - * others are no-ops (settle-exactly-once). + * Drive the in-flight prompt's settle from the harness event stream. The bridge + * settles off the durable log: the `turn/end` session event on the + * `session/event` feed for the prompt's own turn, with the agent + * erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor + * cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener + * starved the bridge's listener before it saw the boundary. The first of these + * to fire settles the prompt; `settle` is then cleared so the 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' - // Capture the injected services NOW, during apply(), while we are inside this // plugin's fiber (where `inject` grants access). The ACP method handlers run // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is @@ -283,7 +268,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // sessionUpdate returns a promise; a closed connection rejects it. The // update is best-effort UI feed, never load-bearing for correctness, so a // throwing/rejecting send must not break the turn (the chunk is emitted - // inside the model step — see AGENTS.md "contain callback exceptions"). + // inside the model step — see docs/defensive-patterns.md "contain callback exceptions"). /* v8 ignore next 3 -- the rejection only fires on a stdout/connection write failure (closed pipe), which the in-memory test transport never induces; the swallow is a defensive best-effort guard like the loop's emit traps */ @@ -318,15 +303,14 @@ export function apply(ctx: Context, config: AcpConfig): void { // the canonical log: every assistant/chunk and tool/call/result is logged, so // translating from the log makes live streaming and `session/load` replay // share the identical path (streamSessionEventUpdate). Both the owning-turn - // capture and the settle key off the log's own `turn/start`/`turn/end` — NOT - // the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER - // listener (cordis `emit` stops at the first throw) or a boundary-emit failure - // can skip. `closeTurn` appends `turn/end` to the log unconditionally, and - // `turn/start` is appended before any step runs, so within this one listener - // we always see the prompt's turn-start (tag `inflight.turn`) then its - // turn-end (settle). A `turn/end` settles the prompt ONLY when it is the - // prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous, - // already-cancelled turn whose end arrives late is ignored (see + // capture and the settle key off the log's own `turn/start`/`turn/end` — the + // durable boundary events (there is no agent/* turn mirror). `closeTurn` + // appends `turn/end` to the log unconditionally, and `turn/start` is appended + // before any step runs, so within this one listener we always see the + // prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A + // `turn/end` settles the prompt ONLY when it is the prompt's OWN turn + // (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn + // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux // strictly by session id: a `session/event` is routed to its own record, so @@ -430,7 +414,9 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true return Promise.resolve({ protocolVersion, - agentInfo: { name: agentName, version: agentVersion }, + // Fixed server identity: this bridge IS the harness ACP server, so the + // branding is a literal, not config (no shipped surface sets it). + agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, agentCapabilities: { loadSession: true, // Baseline prompt blocks only: text plus resource_link rendered as @@ -639,7 +625,7 @@ export function apply(ctx: Context, config: AcpConfig): void { conn = new AgentSideConnection(makeAgent, stream) /** - * Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach + * Tear ALL live sessions down to quiescence (docs/defensive-patterns.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 * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the @@ -716,10 +702,9 @@ export function apply(ctx: Context, config: AcpConfig): void { * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. */ -export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?: string } { +export function agentOptions(config: AcpConfig): { model?: string } { return { ...config.model !== undefined ? { model: config.model } : {}, - ...config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}, } } @@ -813,66 +798,13 @@ export function streamSessionEventUpdate( return } case 'tool/call': { - const present = presenter.call(event.data.callId, event.data.name, event.data.arguments) - // A terminal-rendered call (a shell command) gets a terminal CARD when the - // client supports it: a `terminal` content block plus `_meta.terminal_info` - // (the cwd header). Otherwise it is an ordinary tool_call and the output - // arrives as text on the result. See the terminal-rendering RFC. - const asTerminal = present.terminal !== undefined && terminal.enabled - // The tool's pending content (e.g. bash's `description`) renders ABOVE the - // card; when the card is shown, append the terminal block AFTER it so the - // description sits over the command (Zed renders content blocks in order). - // Without the capability the description still renders as the card's body. - const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [ - ...present.content !== undefined ? toolResultContent(present.content) : [], - ...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [], - ] - notify({ - sessionId, - update: { - sessionUpdate: 'tool_call', - toolCallId: event.data.callId, - title: present.title, - kind: present.kind, - status: 'in_progress', - ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {}, - ...callContent.length > 0 ? { content: callContent } : {}, - ...asTerminal - ? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } } - : {}, - }, - }) + const view = presenter.call(event.data.callId, event.data.name, event.data.arguments) + notify({ sessionId, update: toolCallUpdate(event.data.callId, view, terminal) }) return } case 'tool/result': { - const present = presenter.result(event.data.callId, event.data.content, event.data.isError) - const term = present.terminal - // When the call rendered as a terminal AND the client is capable, the output - // and exit status ride on `_meta` (the terminal card consumes them) and the - // text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's - // content collection in Zed, so sending the fenced ```console block here - // would clobber the terminal content block the call installed. The incapable - // path keeps sending `content` (the fenced fallback is the only rendering). - const asTerminal = term?.output !== undefined && terminal.enabled - const terminalResultMeta = asTerminal - ? { - _meta: { - terminal_output: { terminal_id: event.data.callId, data: term.output }, - ...terminalExitMeta(event.data.callId, term), - }, - } - : {} - notify({ - sessionId, - update: { - sessionUpdate: 'tool_call_update', - toolCallId: event.data.callId, - status: event.data.isError ? 'failed' : 'completed', - ...asTerminal ? {} : { content: toolResultContent(present.content) }, - ...present.title !== undefined ? { title: present.title } : {}, - ...terminalResultMeta, - }, - }) + const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta) + notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) }) return } case 'todo/write': { @@ -915,44 +847,20 @@ export interface TerminalRendering { /** Default: terminal rendering off (the ` ```console ` text fallback path). */ const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } -/** - * Resolved pending-state presentation the bridge feeds into a `tool_call` - * update: a title is always present (tool name when the tool gives none), `kind` - * and `rawInput` are optional. - */ -interface ResolvedCallPresentation { - title: string - kind: ToolCallKind - rawInput?: unknown - /** UI content shown on the pending call (e.g. a bash description text block above the card). */ - content?: ContentBlock[] - /** Tool's request to render as a terminal (the pending side carries the cwd). */ - terminal?: ToolTerminal -} - -/** Resolved completed-state presentation fed into a `tool_call_update`. */ -interface ResolvedResultPresentation { - /** UI content for the result (harness blocks; the tool may reformat, else the raw result). */ - content: ContentBlock[] - /** Optional replacement title for the completed call. */ - title?: string - /** Tool's terminal output/exit for a terminal-rendered call (the result side). */ - terminal?: ToolTerminal -} - /** * Resolves tool-owned presentation for a session's tool-call events. A tool - * declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up - * by name in the registry and applies the generic fallback when a tool defines - * neither. + * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a + * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up + * by name in the registry and applies a generic fallback when a tool defines + * neither. The returned view is what {@link streamSessionEventUpdate} switches on. * - * The `tool/result` session event carries only `{ callId, content, isError }` — - * NOT the tool name or args — so to call a tool's `presentResult` (which needs - * both), the presenter remembers each `tool/call`'s `{ name, args }` keyed by - * callId and looks it up on the matching result. The map is bridge-LOCAL (not a - * change to the event schema or a core service): one presenter per live session - * (and a throwaway per `session/load` replay), and each entry is removed when - * its result arrives. In the normal loop a `tool/call` is always followed by a + * The `tool/result` session event does NOT carry the tool name or args — so to + * call a tool's `presentResult` (which needs both), the presenter remembers each + * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the + * matching result. The map is bridge-LOCAL (not a change to the event schema or a + * core service): one presenter per live session + * (and a throwaway per `session/load` replay), and each entry is removed when its + * result arrives. In the normal loop a `tool/call` is always followed by a * `tool/result` (the registry turns even a thrown tool into an isError result), * so the map holds only currently-in-flight calls. The one exception is a step * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can @@ -962,14 +870,14 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>() + private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>() /** * @param tools the registry to resolve tool definitions by name. * @param onError invoked when a tool's `presentCall`/`presentResult` THROWS; * the presenter swallows the error and falls back to the generic * presentation so a buggy display callback can never fail a live turn or a - * `session/load` replay (AGENTS.md "contain callback exceptions at the + * `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the * boundary"). Defaults to a no-op for callers that don't supply a logger. */ constructor( @@ -977,10 +885,10 @@ export class ToolPresenter { private readonly onError: (message: string) => void = () => {}, ) {} - /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */ - call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation { + /** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */ + call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) - let present: ToolCallPresentation | undefined + let present: ToolCallView | undefined try { present = this.tools.get(name)?.presentCall?.(args) } catch (error: unknown) { @@ -988,50 +896,39 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - if (present === undefined) { - // No tool-owned presentation: fall back to the tool name as the title and - // the full parsed args as the raw input (the pre-seam behavior). A generic - // call is never a terminal, so a later result can't emit terminal output. - this.pending.set(callId, { name, args, isTerminal: false }) - return { title: name, kind: toolKindFor(name), rawInput: args } - } - // Remember whether THIS call rendered as a terminal, so `result()` only emits - // terminal output/exit for a call that actually registered a terminal — a - // `presentResult().terminal` without a matching `presentCall().terminal` - // would otherwise orphan `_meta.terminal_output` to a terminal Zed never made. - this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined }) - return { - title: present.title, - kind: present.kind ?? 'other', - rawInput: present.rawInput, - ...present.content !== undefined ? { content: present.content } : {}, - ...present.terminal !== undefined ? { terminal: present.terminal } : {}, - } + // No tool-owned presentation: fall back to the tool name as the title, the + // full parsed args as the raw input, and kind `other` (the generic card). + // The kind is never sniffed from the name — the bridge does not special-case + // tool names; a tool that wants a richer kind declares `presentCall`. + const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } + this.pending.set(callId, { name, args, card: view.card }) + return view } - /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */ - result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { + /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */ + result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. - if (call === undefined) return { content } - let present: ToolResultPresentation | undefined + if (call === undefined) return { card: 'generic', content } + let present: ToolResultView | undefined try { - present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError }) + present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} }) } catch (error: unknown) { // A throwing presentResult must not break streaming/replay: log + fall back. this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`) present = undefined } - if (present === undefined) return { content } - return { - content: present.content ?? content, - ...present.title !== undefined ? { title: present.title } : {}, - // Only propagate terminal output/exit when the PENDING call registered a - // terminal (finding: orphan terminal output otherwise). A result-only - // terminal with no matching call-side terminal is dropped. - ...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {}, - } + if (present === undefined) return { card: 'generic', content } + // Orphan guard: only honor a `terminal` result when the PENDING call was a + // terminal. A result-only terminal with no matching call-side terminal would + // orphan `_meta.terminal_output` to a terminal Zed never made — drop it back + // to the raw content. + if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content } + // A generic result that reformats no content keeps the RAW result content + // (the tool replaced only the title); fill it so the card is never blanked. + if (present.card === 'generic' && present.content === undefined) return { ...present, content } + return present } } @@ -1041,16 +938,8 @@ export class ToolPresenter { * results pass their raw content through unchanged. */ export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = { - call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }), - result: (_callId, content) => ({ content }), -} - -/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */ -function toolKindFor(name: string): ToolCallKind { - if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute' - if (name === 'read' || name.startsWith('read')) return 'read' - if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit' - return 'other' + call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }), + result: (_callId, content) => ({ card: 'generic', content }), } /** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */ @@ -1075,20 +964,121 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content: return out } +/** The `session/update` payload for a `tool_call` / `tool_call_update`. */ +type ToolCallSessionUpdate = SessionNotification['update'] + +/** An ACP tool-call content block (a text/image `content`, a `diff`, or a `terminal`). */ +type AcpToolCallContent = + | { type: 'content'; content: AcpContentBlock } + | { type: 'diff'; path: string; oldText: string | null; newText: string } + | { type: 'terminal'; terminalId: string } + /** - * Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model - * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session - * cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution, - * so the header matches where the command actually ran); when the tool gives no - * cwd, the session workspace cwd is the default. Returns `undefined` only when - * neither the tool nor the session supplies one (Zed then shows "current - * directory"). + * Relativize a file card's TITLE path against the session workspace cwd, so a + * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the + * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the + * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The + * pure tool presenter can't see the session cwd, so this happens here where the + * bridge knows it. The rewrite is an exact substring replace of the known raw + * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a + * heuristic. A path outside the workspace, or an absent/relative session cwd, is + * left unchanged. */ -function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined { - const toolCwd = term?.cwd - if (toolCwd === undefined) return sessionCwd - if (isAbsolute(toolCwd)) return toolCwd - return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd +function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { + if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title + const rel = relativePath(sessionCwd, rawPath) + // Only relativize a target that stays INSIDE the workspace. `relative` prefixes + // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone + // or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x` + // (a real in-workspace name) still relativizes. Never relativize to the empty + // string (rawPath === cwd — a non-file target). + if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title + return title.split(rawPath).join(rel) +} + +/** + * Resolve the terminal card's header cwd. A `TerminalCallView.cwd` (a model + * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session cwd + * (matching how `dsh-tool-bash` resolves a relative workdir for execution, so the + * header matches where the command actually ran); when the view gives no cwd, the + * session workspace cwd is the default. Returns `undefined` only when neither the + * view nor the session supplies one (Zed then shows "current directory"). + */ +function terminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined { + if (viewCwd === undefined) return sessionCwd + if (isAbsolute(viewCwd)) return viewCwd + return sessionCwd !== undefined ? resolvePath(sessionCwd, viewCwd) : viewCwd +} + +/** + * Build the `tool_call` (pending) `session/update` from a tool's render intent. + * Switches on `view.card`: a `generic` card maps title/kind/rawInput/content/ + * locations; a `diff` card emits `{ type: 'diff' }` content blocks (the editor's + * inline diff) plus follow-along locations; a `terminal` card renders as a + * terminal when the client is capable (a `terminal` content block + the + * `_meta.terminal_info` cwd header) and otherwise falls back to a generic execute + * card whose body is the description. File-card titles are relativized against the + * session cwd (see {@link displayTitle}). + */ +function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRendering): ToolCallSessionUpdate { + switch (view.card) { + case 'generic': + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + // Relativize the title against the session cwd when the card carries a + // file location (a read/file card); a location-less card (bash, todo) + // has no path to relativize, so the title is used as-is. + title: displayTitle(view.title, view.locations?.[0]?.path, terminal.cwd), + kind: view.kind ?? 'other', + status: 'in_progress', + ...view.rawInput !== undefined ? { rawInput: view.rawInput } : {}, + ...view.locations !== undefined ? { locations: view.locations } : {}, + ...view.content !== undefined && view.content.length > 0 ? { content: toolResultContent(view.content) } : {}, + } + case 'diff': { + const rawPath = view.locations?.[0]?.path ?? view.diffs[0]?.path + const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + title: displayTitle(view.title, rawPath, terminal.cwd), + kind: 'edit', + status: 'in_progress', + ...view.locations !== undefined ? { locations: view.locations } : {}, + ...content.length > 0 ? { content } : {}, + } + } + case 'terminal': { + // A terminal-rendered call gets a terminal CARD when the client supports it: + // the description renders ABOVE the card, then the terminal block, plus + // `_meta.terminal_info` (the cwd header). Without the capability it is an + // ordinary execute card whose body is the description and whose rawInput is + // the command; the output arrives as text on the result. + const asTerminal = terminal.enabled + const description: AcpToolCallContent[] = view.description !== undefined + ? [{ type: 'content', content: { type: 'text', text: view.description } }] + : [] + const content: AcpToolCallContent[] = [ + ...description, + ...asTerminal ? [{ type: 'terminal' as const, terminalId: callId }] : [], + ] + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + title: view.title, + kind: 'execute', + status: 'in_progress', + rawInput: view.title, + ...content.length > 0 ? { content } : {}, + ...asTerminal + ? { _meta: { terminal_info: { terminal_id: callId, cwd: terminalCwd(view.cwd, terminal.cwd) } } } + : {}, + } + } + default: + return assertNever(view, 'ToolCallView.card') + } } /** The `terminal_exit` `_meta` entry for a completed terminal call. */ @@ -1098,12 +1088,89 @@ interface TerminalExitMeta { /** * Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta` - * from the tool's terminal result: a `signal` death yields `{signal}`, an - * `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply - * shows no exit pill). Spread into the `_meta` object alongside `terminal_output`. + * from a terminal result: a `signal` death yields `{signal}`, an `exitCode` + * yields `{exit_code}`, and neither yields nothing (the card simply shows no exit + * pill). Spread into the `_meta` object alongside `terminal_output`. */ -function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta { - if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } } - if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } } +function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExitMeta { + if (view.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: view.signal } } + if (view.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: view.exitCode } } return {} } + +/** + * Build the `tool_call_update` (completed) `session/update` from a result render + * intent. A `generic` result sends its reformatted content (or the raw result); + * a `terminal` result rides its output/exit on `_meta` when the client is capable + * (the terminal card consumes them and `content` is OMITTED — a + * `tool_call_update.content` REPLACES the call's content collection in Zed, so + * re-sending would clobber the terminal block the call installed) and otherwise + * derives the fenced ```console fallback from `output`. A `diff` result emits its + * `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a + * create), which replace the diff the call installed — so the model-facing result + * text can never clobber it. + */ +function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { + const status = isError ? 'failed' as const : 'completed' as const + switch (view.card) { + case 'terminal': { + const output = view.output ?? '' + if (terminal.enabled) { + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + ...view.title !== undefined ? { title: view.title } : {}, + _meta: { + terminal_output: { terminal_id: callId, data: output }, + ...terminalExitMeta(callId, view), + }, + } + } + // No terminal capability: the bridge derives the fenced ```console fallback. + const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\`` + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + content: [{ type: 'content', content: { type: 'text', text: fenced } }], + ...view.title !== undefined ? { title: view.title } : {}, + } + } + case 'generic': + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + // The presenter fills a generic result's content from the raw result, so + // `content` is always defined here; the guard keeps this total for a + // directly-constructed view. + /* v8 ignore next -- content always defined via the presenter (see above) */ + ...view.content !== undefined ? { content: toolResultContent(view.content) } : {}, + ...view.title !== undefined ? { title: view.title } : {}, + } + case 'diff': { + // A result-time diff: emit one `{ type: 'diff' }` content block per entry + // (an applied hunk for an edit/overwrite, or a whole-file diff for a + // create), mirroring the call-side diff arm. `tool_call_update.content` + // REPLACES the call's content in an editor, so this result diff supersedes + // the diff the pending card installed (and keeps the model-facing result + // text from clobbering it). + const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) + // Relativize the replacement title against the session cwd from the diff + // path, exactly as the call-side card does — `tool_call_update.title` + // replaces the card header, so a raw absolute path here would undo the + // pending card's relativized title. + const title = view.title !== undefined ? displayTitle(view.title, view.diffs[0]?.path, terminal.cwd) : undefined + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + ...content.length > 0 ? { content } : {}, + ...title !== undefined ? { title } : {}, + } + } + default: + return assertNever(view, 'ToolResultView.card') + } +} diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index e9ebca8d62..1e7e9ae511 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -20,7 +20,7 @@ describe('acp bridge', () => { }) afterEach(async () => { - // e2e/integration tests own their resources (AGENTS.md): dispose even on + // e2e/integration tests own their resources (docs/testing.md): dispose even on // failure so a flaky run never leaks a context or persistence dir. if (harness) await harness.dispose() harness = undefined @@ -33,7 +33,7 @@ describe('acp bridge', () => { expect(res.protocolVersion).toBe(PROTOCOL_VERSION) expect(res.agentCapabilities?.loadSession).toBe(true) expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false }) - expect(res.agentInfo?.name).toBe('deepseek-harness-acp') + expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' }) }) it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => { @@ -148,16 +148,15 @@ describe('acp bridge', () => { await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined() }) - it('honors agentName/agentVersion/systemPrompt config', async () => { + it('renders the deployment persona into ACP-created agents\' requests', async () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')], - config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' }, + persona: 'be terse', }) - const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' }) - // Create + prompt so the systemPrompt config flows through agentOptions and - // reaches the model request. + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // Create + prompt so the system-prompt plugin's persona section reaches + // the model request of an agent the BRIDGE created (session/new). const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] }) expect(harness.adapter.requests[0]?.system).toContain('be terse') diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 9d82fe7533..b4f0c10792 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' import { @@ -16,6 +17,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: 'rejected', reason: 'blocked by hook' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn') }) @@ -32,9 +34,9 @@ describe('harnessBlockToAcpContent', () => { expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' }) }) - it('returns undefined for non-text blocks (reasoning/tool/image)', () => { + it('returns undefined for non-text blocks (reasoning / plugin-added)', () => { expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined() - expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined() + expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined() }) }) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 9077d106d0..39d77fe624 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { ClientSideConnection, @@ -150,12 +153,14 @@ export interface BridgeHarness { export async function makeBridgeHarness(options: { script?: (StreamChunk[] | 'hang')[] config?: Partial<AcpConfig> + /** Deployment persona for the tree (the system-prompt plugin's config). */ + persona?: string storageDir: string /** * Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of * a test's own inline tool). Lets a test drive the actual `bash` tool — its * real `presentCall`/`presentResult` — through the bridge, so tool-call UI - * tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real + * tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real * implementation over a mock in tests"). */ withBash?: boolean @@ -165,13 +170,22 @@ export async function makeBridgeHarness(options: { * tool + the bridge's own todo/write→plan mapping, not a stand-in. */ withTodo?: boolean + /** + * Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` + + * `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge + * and assert their tool-owned presentation (title/kind/`locations`) on the + * wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's + * base directory (default: `storageDir`). + */ + withFs?: boolean + fsCwd?: string } = { storageDir: '' }): Promise<BridgeHarness> { const adapter = new MockAdapter(options.script ?? []) const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) + await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) @@ -183,6 +197,11 @@ export async function makeBridgeHarness(options: { if (options.withTodo) { await ctx.plugin(ToolTodo) } + if (options.withFs) { + await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir }) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + } ctx.llm.registerAdapter(['mock'], adapter) // Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index c07ef93274..2fbc95b3d9 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -62,7 +62,7 @@ describe('acp bridge — session/load replay', () => { // bridge. The replayed tool_call/tool_call_update must carry the tool's OWN // presentation — identical to how it streamed live — via a throwaway // presenter that pairs call→result as the log replays in order. Uses the - // shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real + // shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real // implementation over a mock in tests"). live = await makeBridgeHarness({ storageDir, diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 89d68fd1c0..cb3eab3545 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' 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 type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import FsLocal from '@deepseek-ai/dsh-fs-local' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts' /** Collect the updates a single event produces (no presenter → generic fallback). */ @@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] { } /** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */ -function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> { +function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> { const map = new Map(tools.map(t => [t.name, t])) return { get: name => map.get(name) } } @@ -45,32 +50,34 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) - it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => { + it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'bash', - kind: 'execute', + // The fallback never sniffs a kind from the tool name — even a name a + // first-party tool uses (`bash`) renders `other`; kinds are tool-owned + // via presentCall. + kind: 'other', status: 'in_progress', rawInput: { command: 'ls' }, }]) }) - it('infers tool kinds: read*/write*/edit*/other', () => { - const kind = (name: string): unknown => - updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0] - expect((kind('read_file') as { kind: string }).kind).toBe('read') - expect((kind('write') as { kind: string }).kind).toBe('edit') - expect((kind('edit_file') as { kind: string }).kind).toBe('edit') - expect((kind('frobnicate') as { kind: string }).kind).toBe('other') - }) - it('falls back to the raw argument string when tool arguments are not JSON', () => { const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0] expect((update as { rawInput: unknown }).rawInput).toBe('not json') }) + it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => { + // `JSON.parse('')` throws, so without the empty-string guard a zero-arg + // call would render `rawInput: ''` via the non-JSON fallback; the guard + // normalizes it to `{}`. + const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0] + expect((update as { rawInput: unknown }).rawInput).toEqual({}) + }) + it('maps tool/result to completed/failed tool_call_update with text content', () => { const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false })) expect(ok).toEqual([{ @@ -86,7 +93,7 @@ describe('streamSessionEventUpdate', () => { it('drops non-text tool-result content (text-only)', () => { const update = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), - content: [{ type: 'image', url: 'https://x/y.png' }], + content: [{ type: 'reasoning', text: 'private' }], isError: false, }))[0] expect((update as { content: unknown[] }).content).toEqual([]) @@ -158,7 +165,7 @@ describe('todosToPlan', () => { }) describe('ToolPresenter (tool-owned presentation via the tool registry)', () => { - /** A tool whose presentCall/presentResult mirror what tool-bash declares. */ + /** A tool whose presentCall/presentResult return generic-card views. */ const bashLike: ToolDefinition = { name: 'bash', description: 'run a command', @@ -166,9 +173,10 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => execute: async () => [], presentCall: (args: unknown) => { const a = args as { command: string; description: string } - return { title: a.description, kind: 'execute', rawInput: a.command } + return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command } }, presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({ + card: 'generic', content: [{ type: 'text', text: `wrapped:${result.content.length}` }], }), } @@ -242,8 +250,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => description: 'm', parameters: {}, execute: async () => [], - presentCall: () => ({ title: 'Doing a thing' }), - presentResult: () => ({ title: 'Did the thing' }), + presentCall: () => ({ card: 'generic', title: 'Doing a thing' }), + presentResult: () => ({ card: 'generic', title: 'Did the thing' }), } const presenter = new ToolPresenter(registryOf(minimal)) const updates = updatesWith( @@ -281,7 +289,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => { // A buggy tool whose display callbacks throw must NOT fail a live turn or a - // session/load replay (AGENTS.md "contain callback exceptions at the + // session/load replay (docs/defensive-patterns.md "contain callback exceptions at the // boundary"). The presenter swallows the throw, reports via onError, and // falls back to the generic presentation. const boom: ToolDefinition = { @@ -330,29 +338,106 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' }) expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] }) }) + + it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => { + // The bridge switches on `view.card` and ends with assertNever: a rogue card + // (only reachable by a cast — the union is closed) must throw, so adding a + // real variant later fails to compile at the switch instead of silently + // dropping the card. + const rogue: ToolDefinition = { + name: 'rogue', + description: 'r', + parameters: {}, + execute: async () => [], + // A card value outside the union — forced with a cast (no valid input reaches this). + presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>, + } + const presenter = new ToolPresenter(registryOf(rogue)) + expect(() => updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}', + }))).toThrow('unreachable variant') + }) + + it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => { + // The result-side renderer is also an exhaustive switch + assertNever: a rogue + // result card (only reachable by a cast) must throw, so adding a real result + // variant later fails to compile at the switch. + const rogue: ToolDefinition = { + name: 'rogue', + description: 'r', + parameters: {}, + execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'r' }), + presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>, + } + const presenter = new ToolPresenter(registryOf(rogue)) + expect(() => updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }), + )).toThrow('unreachable variant') + }) + + it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => { + // Use the SHIPPING fs tools (not a stand-in), booted through their real + // plugins, so the wire tool_call carries the actual presentCall output — + // read's follow-along `locations` and edit's `diff` content block. (docs/testing.md + // "prefer the real implementation over a mock".) + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + const presenter = new ToolPresenter(ctx.tools) + + const [readCall] = updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('r1'), name: 'read', + arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }), + })) + // A generic card: the read window is in the title, the offset drives the + // follow-along location line. No rawInput (the window lives in the title). + expect(readCall).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read', + locations: [{ path: 'src/a.ts', line: 12 }], + }) + expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined() + + const [editCall] = updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('e1'), name: 'edit', + arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }), + })) + // A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the + // literal old→new replacement, plus the follow-along location. + expect(editCall).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit', + locations: [{ path: 'src/b.ts' }], + content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }], + }) + await ctx.fiber.dispose() + }) }) describe('terminal-card mapping (capability-gated)', () => { - // A tool that asks to render as a terminal — a stand-in for tool-bash's shape, - // letting us drive the bridge's terminal mapping without the real executor. - type CallTerm = { cwd?: string } | undefined - type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined - const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({ + // A tool that renders as a terminal — a stand-in for tool-bash's shape, letting + // us drive the bridge's terminal mapping without the real executor. `callCard` + // selects a terminal call view (optionally with a cwd) or a generic one (for the + // orphan-guard test); `resultTerminal` is the terminal result view's output/exit. + type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' } + type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string } + const termTool = (callCard: CallCard, resultTerminal: ResultTerm): ToolDefinition => ({ name: 'bash', description: 'run a command', parameters: {}, execute: async () => [], - presentCall: (args: unknown) => ({ - title: (args as { command: string }).command, - kind: 'execute', - rawInput: (args as { command: string }).command, - content: [{ type: 'text', text: (args as { description: string }).description }], - ...callTerminal !== undefined ? { terminal: callTerminal } : {}, - }), - presentResult: () => ({ - content: [{ type: 'text', text: 'fallback' }], - ...resultTerminal !== undefined ? { terminal: resultTerminal } : {}, - }), + presentCall: (args: unknown) => { + const command = (args as { command: string }).command + const description = (args as { description: string }).description + if (callCard.card === 'terminal') { + return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} } + } + return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] } + }, + presentResult: () => ({ card: 'terminal', ...resultTerminal }), }) const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) }) @@ -366,7 +451,7 @@ describe('terminal-card mapping (capability-gated)', () => { } it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => { - const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent) + const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent) expect(call).toMatchObject({ sessionUpdate: 'tool_call', content: [ @@ -385,33 +470,33 @@ describe('terminal-card mapping (capability-gated)', () => { }) it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => { - const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) + const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs') - const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent) + const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent) // Relative workdir resolved against the session cwd — the card header matches // where execution actually ran (tool-bash resolves the same way). expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir') // No session cwd to resolve against → the relative tool cwd is passed through as-is. - const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) + const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only') }) it('capability ON: a signal kill maps to terminal_exit.signal', () => { - const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent) + const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent) expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' }) }) it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => { // A terminal-rendering tool that reports no structured exit (neither exitCode // nor signal) — the card shows output but no exit pill. - const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent) + const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'partial' }), true, '/w', callEvent, resultEvent) const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' }) expect(meta.terminal_exit).toBeUndefined() }) - it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => { - const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent) + it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => { + const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent) expect(call).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', @@ -421,24 +506,311 @@ describe('terminal-card mapping (capability-gated)', () => { rawInput: 'echo hi', content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }], }) + // The bridge derives the fenced ```console fallback from the terminal output. expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', - content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }], + content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], }) }) - it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => { - // presentCall declares NO terminal, but presentResult returns one — the - // bridge must not emit _meta.terminal_output for a terminal Zed never made. - const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent) - // The call had no terminal → ordinary tool_call (description content, no _meta). + it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => { + // presentCall is a generic card, but presentResult returns a terminal view — + // the bridge must not emit _meta.terminal_output for a terminal Zed never made. + const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent) + // The call was generic → ordinary tool_call (description content, no _meta). expect((call as { _meta?: unknown })._meta).toBeUndefined() expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }]) - // The result falls back to text content; NO terminal _meta. + // The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta. expect((update as { _meta?: unknown })._meta).toBeUndefined() - expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }]) + expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }]) + }) + + it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => { + // A terminal result MAY carry a replacement title and MAY omit output (a run + // that produced nothing) — the _meta carries empty data, not a dropped key. + const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent) + expect(update).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + title: 'Ran echo', + _meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } }, + }) + }) + + it('capability OFF: a terminal result title rides on the fenced fallback update', () => { + const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent) + expect(update).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], + title: 'Ran echo', + }) + }) + + it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => { + // A terminal view whose presentCall omits `description`, with the capability + // OFF: no description block and no terminal block → the card carries no content. + const noDesc: ToolDefinition = { + name: 'bash', + description: 'run a command', + parameters: {}, + execute: async () => [], + presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }), + } + const [call] = termUpdates(noDesc, false, undefined, callEvent) + expect(call).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'echo hi', + kind: 'execute', + status: 'in_progress', + rawInput: 'echo hi', + }) + }) +}) + +describe('diff-card mapping', () => { + // A stand-in diff tool, letting us drive the bridge's diff arm across shapes + // the shipping fs tools don't emit (no locations, empty diffs). + const diffTool = (view: unknown): ToolDefinition => ({ + name: 'writer', + description: 'writes a file', + parameters: {}, + execute: async () => [], + presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>, + }) + function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] { + const presenter = new ToolPresenter(registryOf(tool)) + const out: SessionNotification['update'][] = [] + streamSessionEventUpdate( + SessionId('s1'), + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }), + n => out.push(n.update), + presenter, + { enabled: false, cwd }, + ) + return out[0]! + } + + it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => { + const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj') + expect(update).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'Write a.txt', + kind: 'edit', + status: 'in_progress', + content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }], + }) + }) + + it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => { + const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined) + expect(update).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'Write nothing', + kind: 'edit', + status: 'in_progress', + }) + }) +}) + +describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => { + // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call + // installs the call-time snippet, then the tool/result carries the tool's + // computed applied-hunk `meta`, which presentResult narrows into a `diff` + // result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses + // the REAL tool (not a stand-in) per the anti-mock convention, mirroring the + // call-side diff test above. + async function fsCtx(): Promise<Context> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx + } + + function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { + const out: SessionNotification['update'][] = [] + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) + return out + } + + it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => { + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + // The applied hunk the tool would compute and persist on the result meta. + const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), + ) + expect(resultUpdate).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'e1', + status: 'completed', + title: 'Edit src/b.ts', + content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], + }) + await ctx.fiber.dispose() + }) + + it('an error result carries NO diff card (falls back to raw content)', async () => { + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }), + ) + expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' }) + expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })])) + await ctx.fiber.dispose() + }) + + it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => { + // A `tool_call_update.title` replaces the card header, so the result-side + // diff must relativize its title exactly as the pending card did — otherwise + // a completed absolute-path edit flips `Edit src/b.ts` back to the raw + // absolute path. The diff/location paths stay absolute (the editor opens the + // real path). Drive the REAL fs edit tool with an absolute in-workspace path. + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } + const out: SessionNotification['update'][] = [] + const rendering = { enabled: false, cwd: '/work/proj' } + for (const event of [ + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), + ]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering) + expect(out[1]).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'e1', + status: 'completed', + title: 'Edit src/b.ts', + content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], + }) + await ctx.fiber.dispose() + }) + + it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { + // A synthetic tool whose presentResult yields a `diff` card with no hunks and + // no title — the shipping fs tools never emit this (edit always has a hunk; + // write always falls back to a whole-file diff), so a stand-in is the only way + // to exercise the empty-content AND absent-title branches of the result-side + // diff arm. + const emptyDiffTool: ToolDefinition = { + name: 'writer', + description: 'writes a file', + parameters: {}, + execute: async () => [], + presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }), + presentResult: () => ({ card: 'diff', diffs: [] }), + } + const presenter = new ToolPresenter(registryOf(emptyDiffTool)) + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }), + ) + expect(resultUpdate).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'w1', + status: 'completed', + }) + expect(resultUpdate).not.toHaveProperty('content') + expect(resultUpdate).not.toHaveProperty('title') + }) +}) + +describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { + // The bridge relativizes a file card's TITLE against the session workspace cwd + // (mirroring the reference adapter's toDisplayPath), while leaving locations/ + // diff paths RAW. Drive it with the REAL fs tools so the title/locations come + // from the shipping presentCall, and pass an ABSOLUTE file path (which a real + // editor forwards). The presenter is pure/args-only; the cwd is known only here. + async function fsCtx(): Promise<Context> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx + } + function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] { + const presenter = new ToolPresenter(ctx.tools) + const out: SessionNotification['update'][] = [] + streamSessionEventUpdate( + SessionId('s1'), + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }), + n => out.push(n.update), + presenter, + { enabled: false, cwd: sessionCwd }, + ) + return out[0]! + } + + it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 }) + expect(update).toMatchObject({ + title: 'Read src/a.ts (from line 5)', + locations: [{ path: '/work/proj/src/a.ts', line: 5 }], + }) + await ctx.fiber.dispose() + }) + + it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' }) + expect(update).toMatchObject({ + title: 'Edit src/b.ts', + locations: [{ path: '/work/proj/src/b.ts' }], + content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }], + }) + await ctx.fiber.dispose() + }) + + it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' }) + expect((update as { title: string }).title).toBe('Read /etc/passwd') + await ctx.fiber.dispose() + }) + + it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => { + // `/work/proj/..cache/x` is INSIDE the workspace — its relative form + // `..cache/x` begins with the chars `..` but is NOT a parent segment. The + // guard tests for a `..` SEGMENT, so this relativizes (matching the reference + // adapter, which accepts any target under `cwd + sep`). + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' }) + expect((update as { title: string }).title).toBe('Read ..cache/x.ts') + await ctx.fiber.dispose() + }) + + it('no session cwd → the absolute title is left unchanged', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' }) + expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts') + await ctx.fiber.dispose() + }) + + it('a relative path is passed through unchanged (already display-friendly)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' }) + expect((update as { title: string }).title).toBe('Read src/a.ts') + await ctx.fiber.dispose() }) }) @@ -446,7 +818,5 @@ describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) - expect(agentOptions({ systemPrompt: 'sp' })).toEqual({ systemPrompt: 'sp' }) - expect(agentOptions({ model: 'm', systemPrompt: 'sp' })).toEqual({ model: 'm', systemPrompt: 'sp' }) }) }) diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 7634602a35..e182d5f2bf 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => { const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call') const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update') expect(toolCalls).toHaveLength(1) - expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' }) + // The inline stand-in declares no presentCall, so the generic fallback + // renders kind `other` (kinds are tool-owned; the bridge never sniffs the + // name — the REAL dsh-tool-bash test below covers the execute card). + expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' }) expect(toolUpdates).toHaveLength(1) expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' }) @@ -79,7 +82,7 @@ describe('acp bridge — turn outcomes', () => { it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => { // Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline // stand-in, so this verifies the actual presentCall/presentResult the editor - // sees (AGENTS.md "prefer the real implementation over a mock in tests"). + // sees (docs/testing.md "prefer the real implementation over a mock"). // The mock MODEL still scripts the tool call (no real LLM needed), but the // tool and executor are real: a real `echo` runs and its real output flows // back through the bridge. diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md new file mode 100644 index 0000000000..92fbc333be --- /dev/null +++ b/packages/ui/app-boot/README.md @@ -0,0 +1,15 @@ +# `@deepseek-ai/dsh-app-boot` + +Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. + +| Export | Role | +|---|---| +| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | +| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | +| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | + +Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. + +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json new file mode 100644 index 0000000000..67b8b00dbc --- /dev/null +++ b/packages/ui/app-boot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-app-boot", + "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts new file mode 100644 index 0000000000..5515501188 --- /dev/null +++ b/packages/ui/app-boot/src/index.ts @@ -0,0 +1,154 @@ +/** + * Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load + * the gitignored `.env`, install the fail-loud Loader guards, resolve the + * config path (snapshot-aware), and drive the cordis Loader against a leaf + * `cordis.yml` until the whole tree has settled. Each bin stays a thin + * self-executing composition over these helpers, parameterized by its + * diagnostic prefix; the loader-failure lore lives here, once, under the + * per-file coverage gate. + * + * Two failure classes the guards handle: + * + * - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses + * `Promise.allSettled`, which swallows rejections). A plugin whose + * `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()` + * resolves — Node's default handler already exits non-zero, and + * {@link installFailLoud} replaces the noisy dump with one labelled stderr + * line and a guaranteed `exit(1)`. + * - A plugin module that fails to IMPORT is caught and only LOGGED by the + * cordis Loader (`entry._init`), leaving the entry with no `fiber` and + * producing no rejection — the process would otherwise exit 0 with a usable + * config typo reported only as a log line; {@link assertEntriesLoaded} makes + * `boot()` reject on any such entry instead of returning a half-empty + * context. + * + * @module @deepseek-ai/dsh-app-boot + */ + +import { pathToFileURL } from 'node:url' +import { basename, dirname, resolve } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +/** + * Resolve the config to boot, honoring snapshot REPLAY. Given the requested + * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in + * the SAME directory (the keyless replay tree). Other modes — including no + * snapshot mode at all — use the path as-is. Returns an absolute path resolved + * from `cwd`. + */ +export function resolveConfigPath( + configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} + +/** + * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in + * `dir` (Node native `process.loadEnvFile`). An absent file is fine — the + * environment may already carry the variables; the leaf `cordis.yml` reads + * them via the `!!js` tag. A present-but-unreadable `.env` is a real + * misconfiguration: surface it via `warn` (one line, default stderr) rather + * than silently running with the wrong environment. + */ +export function loadEnv( + binName: string, dir: string = process.cwd(), + warn: (line: string) => void = line => void process.stderr.write(line), +): void { + try { + process.loadEnvFile(resolve(dir, '.env')) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + warn(`${binName}: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + } +} + +/** + * The slice of `process` {@link installFailLoud} needs — injectable so tests + * exercise the handler without registering on (or exiting) the real process. + */ +export interface FailLoudProcess { + on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown + off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown + stderr: { write(chunk: string): unknown } + exit(code: number): void +} + +/** + * Make a load failure fail loud with a clear message on stderr. Covers the + * failure path {@link assertEntriesLoaded} cannot: an include whose + * `[Service.init]` throws (e.g. a config FILE that does not exist in a real + * directory) surfaces as an unhandled promise rejection AFTER `boot()` + * resolves. Node's default handler already exits non-zero on an unhandled + * rejection; this replaces the noisy stack dump with a single labelled line on + * STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and + * guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller + * (tests use it; the bins run until exit and never do). + */ +export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { + const handler = (err: unknown): void => { + proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) + proc.exit(1) + } + proc.on('unhandledRejection', handler) + return () => void proc.off('unhandledRejection', handler) +} + +/** + * After the tree settles, assert every loader entry actually started. A + * started entry has a `fiber`; an entry with `fiber === undefined` after the + * tree settled never loaded (its module failed to import), so throw and let + * `boot()` reject instead of returning a half-empty context. A `disabled` + * entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately + * skips `init()` for it — a valid "plugin turned off" config, not a failed + * import — so it is excluded. + */ +export function assertEntriesLoaded(ctx: Context, binName: string): void { + const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) + if (failed.length > 0) { + const names = failed.map(entry => entry.options.name).join(', ') + throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`) + } +} + +/** + * Boot the Loader against `absoluteConfigPath` and return the root context + * once the whole tree has settled. The include is handed the config's ABSOLUTE + * `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl` + * (an absolute URL ignores the base) and can never fall back to the cwd; + * `baseUrl` is still pinned to the config's directory so the config's OWN + * relative plugin/include paths resolve against it. + * + * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns + * once the include ENTRY is registered, but the include then loads its child + * plugins asynchronously — without awaiting the tree, `boot()` would resolve + * while the app's plugins are still mounting, and a CLI process with no + * attached handles yet exits 0 silently. Failures surface two ways: an entry + * whose module failed to import is caught here by {@link assertEntriesLoaded} + * (this `boot()` rejects); an init that THROWS surfaces as an unhandled + * rejection caught by {@link installFailLoud} (installed by the bin first). + * + * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) + * are resolved by the cordis Loader's internal module loader, which is only + * active under `node --expose-internals`; a consumer running a built bin must + * pass that flag (or install the plugins where node hoists them). Relative + * specifiers resolve against the config directory with no flag. + */ +export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + await ctx.plugin(Loader) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: pathToFileURL(absoluteConfigPath).href }, + }) + await ctx.loader.await() + assertEntriesLoaded(ctx, binName) + return ctx +} diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts new file mode 100644 index 0000000000..510186ebb0 --- /dev/null +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Context } from 'cordis' +import { + assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath, + type FailLoudProcess, +} from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) + +describe('resolveConfigPath', () => { + it('resolves relative to the given cwd outside replay mode', () => { + expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml')) + expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml')) + }) + + it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => { + expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml')) + expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml')) + }) + + it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => { + expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml')) + expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml')) + }) +}) + +describe('loadEnv', () => { + it('loads variables from .env in the given dir', () => { + const dir = tmp() + writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n') + const warn = vi.fn() + loadEnv(NAME, dir, warn) + expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded') + expect(warn).not.toHaveBeenCalled() + delete process.env['DSH_APP_BOOT_SPEC_VAR'] + }) + + it('stays silent when no .env exists (ambient environment wins)', () => { + const warn = vi.fn() + loadEnv(NAME, tmp(), warn) + expect(warn).not.toHaveBeenCalled() + }) + + it('warns (labelled, single line) when .env exists but cannot be loaded', () => { + const dir = tmp() + mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file + const warn = vi.fn() + loadEnv(NAME, dir, warn) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `)) + }) + + it('defaults dir to the process cwd and warn to a stderr write', () => { + const dir = tmp() + writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n') + const previous = process.cwd() + process.chdir(dir) + try { + loadEnv(NAME) // happy path: the default warn sink is never invoked + } finally { + process.chdir(previous) + } + expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes') + delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS'] + // The default warn sink itself: point it at a broken .env with stderr + // spied, so the arrow body runs without polluting the test output. + const broken = tmp() + mkdirSync(join(broken, '.env')) + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + let written: string[] + try { + loadEnv(NAME, broken) + written = write.mock.calls.map(call => String(call[0])) + } finally { + write.mockRestore() + } + expect(written).toHaveLength(1) + expect(written[0]).toContain(`${NAME}: failed to load .env: `) + }) +}) + +describe('installFailLoud', () => { + function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { + const handlers: Array<(err: unknown) => void> = [] + const written: string[] = [] + const exits: number[] = [] + return { + handlers, written, exits, + on: (_event, handler) => { handlers.push(handler) }, + off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) }, + stderr: { write: (chunk: string) => { written.push(chunk) } }, + exit: (code: number) => { exits.push(code) }, + } + } + + it('writes one labelled line with the stack and exits 1 on an Error rejection', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + const error = new Error('boom') + proc.handlers[0]!(error) + expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) + expect(proc.written[0]).toContain(error.stack) + expect(proc.exits).toEqual([1]) + }) + + it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + proc.handlers[0]!('plain failure') + expect(proc.written[0]).toContain('plain failure') + const stackless = new Error('no stack') + delete (stackless as { stack?: string }).stack + proc.handlers[0]!(stackless) + expect(proc.written[1]).toContain('no stack') + expect(proc.exits).toEqual([1, 1]) + }) + + it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { + const proc = fakeProc() + const uninstall = installFailLoud(NAME, proc) + expect(proc.handlers).toHaveLength(1) + uninstall() + expect(proc.handlers).toHaveLength(0) + // Default-proc arm: install on the real process, then immediately uninstall + // so the suite leaks no handler and can never exit the runner. + const before = process.listenerCount('unhandledRejection') + const uninstallReal = installFailLoud(NAME) + expect(process.listenerCount('unhandledRejection')).toBe(before + 1) + uninstallReal() + expect(process.listenerCount('unhandledRejection')).toBe(before) + }) +}) + +describe('assertEntriesLoaded', () => { + const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context => + ({ loader: { entries: () => entries } }) as unknown as Context + + it('passes when every enabled entry has a fiber', () => { + expect(() => { assertEntriesLoaded(ctxWith([ + { fiber: {}, options: { name: 'a' } }, + { disabled: true, options: { name: 'off' } }, + ]), NAME) }).not.toThrow() + }) + + it('throws naming every enabled fiber-less entry', () => { + expect(() => { assertEntriesLoaded(ctxWith([ + { fiber: {}, options: { name: 'ok' } }, + { options: { name: 'broken-a' } }, + { options: { name: 'broken-b' } }, + ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`) + }) +}) + +describe('boot', () => { + it('boots a leaf config through the real Loader and settles the tree', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entries = [...ctx.loader.entries()] + expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { + const dir = tmp() + writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json new file mode 100644 index 0000000000..3171312de4 --- /dev/null +++ b/packages/ui/app-boot/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + } + ] +} diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index a78df0fa72..fd608b7888 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | -| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent | +| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`. +`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. 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. @@ -24,19 +24,19 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | -| `systemPrompt` | (required) | the `main` agent's system prompt | +| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | ## 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 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. +`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:repl` scripts invoke it that way. ## Example leaf `cordis.yml` ```yaml -# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app. +# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app. - id: hmr name: '@cordisjs/plugin-hmr' config: @@ -54,7 +54,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte name: '@deepseek-ai/dsh-stdio-agent' config: model: deepseek-v4-flash - systemPrompt: 'You are a CLI coding assistant. Your only tools are bash…' + persona: 'You are a coding assistant powered by the {{model}} model.' ``` 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 index bc9c98a411..0dffe49211 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -32,24 +32,26 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@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:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@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 index 92cd9f5e90..7056486996 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -2,139 +2,24 @@ /** * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that * loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM - * adapter and a bash executor). Owns the boot glue the three `examples/*` once - * duplicated in their `start.ts`: load the gitignored repo-root `.env`, then - * drive the cordis Loader against the config path (default `./cordis.yml`). + * adapter and a bash executor). The boot glue — `.env` loading, the fail-loud + * Loader guards, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin. * - * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding` - * scripts invoke it with the example's config. + * Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The + * `demo:echo` / `demo:repl` scripts invoke it with the example's config. * * @module @deepseek-ai/dsh-stdio-agent/bin */ -import { pathToFileURL } from 'node:url' -import { dirname, resolve } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -/** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the - * CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file - * is fine — the environment may already carry the variables; the leaf - * `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed - * `.env` is a real misconfiguration: surface it on stderr rather than silently - * running with the wrong environment. The mock-model demo (echo) ships no key - * and simply has no `.env`. - */ -function loadEnv(): void { - try { - process.loadEnvFile(resolve(process.cwd(), '.env')) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} +const NAME = 'dsh-stdio-agent' -/** - * Make a load failure fail loud with a clear message on stderr. Covers the - * failure path the entry-tree check below cannot: when the include's - * `[Service.init]` throws (e.g. a config FILE that does not exist in a real - * directory), the cordis Loader surfaces it as an unhandled promise rejection - * AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because - * `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections. - * Node's default handler already exits non-zero on an unhandled rejection, so - * this does not change the exit code; it replaces Node's noisy stack dump with a - * single labelled line and guarantees `process.exit(1)`. Install before `boot()`. - */ -export function installFailLoud(): void { - process.on('unhandledRejection', (err: unknown) => { - process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - process.exit(1) - }) -} - -/** - * After the tree settles, assert every loader entry actually started. This is - * the load-bearing guard against the SILENT-exit-0 bug: when a plugin module - * fails to IMPORT (e.g. a config path in a non-existent directory, so the include - * plugin itself cannot be resolved), the cordis Loader catches the import error - * and only LOGS it (`entry._init`), leaving the entry with no `fiber` and - * producing no rejection — so the process would otherwise exit 0 with a usable - * config typo reported only as a log line. A started entry has a `fiber`; an - * entry with `fiber === undefined` after the tree settled never loaded. Throw on - * any such entry so `boot()` rejects (and the top-level `await` fails the process - * non-zero) instead of returning a half-empty context. - * - * A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()` - * deliberately skips `init()` for it, so it settles without a fiber by design. - * That is a valid config (a consumer turning an optional plugin off), not a - * failed import — exclude it so the guard catches only real load failures. - */ -function assertEntriesLoaded(ctx: Context): void { - const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) - if (failed.length > 0) { - const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`) - } -} - -/** - * Boot the Loader against `configPath` (resolved from the CWD). The include is - * handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never - * depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall - * back to the cwd. `baseUrl` is still pinned to the config's directory so the - * config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve - * against it. Returns the root context once the whole tree has settled. - * - * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once - * the include ENTRY is registered, but the include then loads its child plugins - * asynchronously. Without awaiting the tree, `boot()` (and `main()`) would - * resolve while the app plugins — the stdin reader, the agent loop — are still - * mounting, and a CLI process with no attached handles yet exits 0 silently. - * Awaiting the tree keeps the process alive until the app's handles are attached. - * - * `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()` - * uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that - * fails to IMPORT leaves an entry with no fiber, caught here by - * {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init - * THROWS surfaces as an unhandled rejection caught by {@link installFailLoud} - * (installed by `main()` before this runs). Together they make any load failure - * exit non-zero with a clear message. - * - * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are - * resolved by the cordis Loader's internal module loader, which is only active - * under `node --expose-internals` (the flag the `demo:echo`/`demo: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<Context> { - const absolute = resolve(process.cwd(), configPath) - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/' - await ctx.plugin(Loader) - await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { path: pathToFileURL(absolute).href }, - }) - await ctx.loader.await() - assertEntriesLoaded(ctx) - return ctx -} - -/** - * Entry point: install the fail-loud guard, load `.env`, then boot the config - * named on argv (default `./cordis.yml`). Awaited at the module top level by the - * published bin (`#!/usr/bin/env node` shebang via the package's `bin` field). - */ -export async function main(argv: string[] = process.argv.slice(2)): Promise<void> { - installFailLoud() - loadEnv() - await boot(argv[0] ?? './cordis.yml') -} - -/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */ -await main() +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and + built-bin smokes */ +installFailLoud(NAME) +loadEnv(NAME) +await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined)) /* v8 ignore stop */ diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index df42b3115b..fe63e02643 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,12 +1,13 @@ /** * The stdio chat app: the providerless agent spine ({@link * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal - * chat needs — a console logger, the readline `ui-stdio` UI, JSONL session + * chat needs — a console logger, the readline UI (the in-package `stdio-chat` + * module), JSONL session * persistence, and a pre-created `main` agent the UI drives. * * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the * console (stdout is just the terminal) and always pre-creates the `main` agent - * `ui-stdio` sends to. The leaf supplies the swappable backends (the LLM + * the readline UI sends to. The leaf supplies the swappable backends (the LLM * adapter, the bash executor), optional product tools, the optional `hmr` * dev-reload plugin, and this app's {@link Config} (model, prompt, persistence * root, welcome banner). @@ -29,8 +30,10 @@ * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray * default would collapse the module to the bare `apply` and drop the `Config` - * namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the - * echo example guards this end-to-end. + * namespace (see docs/postmortem/0001). This app carries no `inject`, so a + * collapsed shape would BOOT rather than crash a smoke — the shape is pinned by + * the explicit `unwrapExports` assertion in this package's unit suite, and the + * keyless echo smoke proves the composed tree runs through the real Loader. * * @module @deepseek-ai/dsh-stdio-agent */ @@ -42,21 +45,22 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import * as uiStdio from '@deepseek-ai/dsh-ui-stdio' +import * as uiStdio from './stdio-chat.ts' export const name = 'stdio-agent' /** * 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); + * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is + * the deployment persona (forwarded to the system-prompt plugin); * `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 + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -71,7 +75,7 @@ export interface Config { export const Config: z<Config> = z.object({ model: z.string().required(), - systemPrompt: z.string().required(), + persona: z.string(), persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), resumeSessionId: z.string(), @@ -80,17 +84,17 @@ export const Config: z<Config> = z.object({ /** * Compose the spine with the stdio front door. The console logger comes first * (infra), then the agent-core bundle pre-creating the `main` agent from this - * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then - * the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf - * concern (see the module doc), so it is not mounted here. + * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL + * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is + * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { + ...config.persona !== undefined ? { persona: config.persona } : {}, agents: [{ id: AgentId('main'), model: config.model, - systemPrompt: config.systemPrompt, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], }) diff --git a/packages/support/ui-stdio/src/index.ts b/packages/ui/stdio-agent/src/stdio-chat.ts similarity index 69% rename from packages/support/ui-stdio/src/index.ts rename to packages/ui/stdio-agent/src/stdio-chat.ts index edfa82285e..2996e11e89 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -1,21 +1,18 @@ /** - * Minimal stdio UI plugin: reads lines from stdin → `agent.send()`/`steer()`, - * and renders the agent's stream chunks and tool activity to stdout. A UI is - * "just a plugin" — it only consumes the `agent/*` event taxonomy and the - * `agents` service, so the same plugin drives any example or product surface. + * The stdio app's readline UI: reads lines from stdin → `agent.send()`/ + * `steer()`, and renders the durable transcript to stdout. A UI is "just a + * plugin" — it consumes the `session/event` feed (the assistant token stream, + * turn/step boundaries, tool activity, todos) plus a few `agent/*` control + * events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` + * service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle + * exit handling, configured via {@link Config}. * - * Consolidates what were two near-identical copies under `examples/echo-agent` - * and `examples/coding-agent` (the latter a superset). This package IS that - * superset: dimmed chain-of-thought rendering plus the robust piped-stdin - * EOF→idle exit handling, configured per consumer via {@link Config}. + * An internal module of the stdio app, not a package of its own: the app's + * front-door cluster always includes this UI, and nothing else composes it. + * The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin + * contract the app's `ctx.plugin(uiStdio, …)` mount consumes. * - * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default - * export — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, - * so a stray default would collapse the module to the bare function and drop - * the `inject` namespace (see docs/postmortem/0001). The keyless Loader-path - * e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. - * - * @module @deepseek-ai/dsh-ui-stdio + * @module @deepseek-ai/dsh-stdio-agent/stdio-chat */ import { createInterface } from 'node:readline' @@ -76,32 +73,48 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime + // Render label lookup: the `turn/start` session event carries only the turn + // number, so to print the short agent id (`[main turn 1]`) we map the + // session's id to its agent's id. The session id is not reliably the agent id + // (a session can be created with an explicit/client-supplied id), so build the + // map from `agent/created` rather than parsing the id string. Seed from the + // registry's current agents first: an agent registered before this plugin + // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR + // reload of just this fiber) already fired its `agent/created`, so the live + // listener alone would miss it and its turns would fall back to the raw + // session id. + const labelBySession = new Map<string, string>() + for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) + ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) + ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + + // Transcript rendering off the durable `session/event` feed — the assistant + // token stream, turn/step boundaries, tool activity, and todos all come from + // the one canonical stream (no agent/* mirrors). A single listener over the + // append order keeps `inReasoning` transitions deterministic across chunk and + // boundary events. let inReasoning = false - ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => { - if (chunk.type === 'reasoning-delta') { - // Dim the chain-of-thought so the final answer stands out. - if (!inReasoning) output.write('\x1B[2m') - inReasoning = true - output.write(chunk.text) - } else if (chunk.type === 'text-delta') { - if (inReasoning) output.write('\x1B[0m\n') + ctx.on('session/event', (session, event) => { + if (event.type === 'assistant/chunk') { + const { chunk } = event.data + if (chunk.type === 'reasoning-delta') { + // Dim the chain-of-thought so the final answer stands out. + if (!inReasoning) output.write('\x1B[2m') + inReasoning = true + output.write(chunk.text) + } else if (chunk.type === 'text-delta') { + if (inReasoning) output.write('\x1B[0m\n') + inReasoning = false + output.write(chunk.text) + } + } else if (event.type === 'turn/start') { + const label = labelBySession.get(session.header.id) ?? session.header.id + output.write(`\n[${label} turn ${event.data.turn}] `) + } else if (event.type === 'turn/end') { + if (inReasoning) output.write('\x1B[0m') inReasoning = false - output.write(chunk.text) - } - }) - - ctx.on('agent/turn-start', (agent, turn) => { - output.write(`\n[${agent.id} turn ${turn}] `) - }) - - ctx.on('agent/turn-end', () => { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write('\n> ') - }) - - ctx.on('session/event', (_session, event) => { - if (event.type === 'tool/call') { + output.write('\n> ') + } else if (event.type === 'tool/call') { const { name: toolName, arguments: args } = event.data if (inReasoning) output.write('\x1B[0m') inReasoning = false diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 7605b35bb7..cda8b41b1f 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'support/invariants', 'support/ui-stdio', + 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', ] diff --git a/packages/support/ui-stdio/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts similarity index 81% rename from packages/support/ui-stdio/tests/readline.spec.ts rename to packages/ui/stdio-agent/tests/readline.spec.ts index c8b147ddab..ae3c480756 100644 --- a/packages/support/ui-stdio/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import type { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' -import type { StdioRuntime } from '../src/index.ts' +import type { StdioRuntime } from '../src/stdio-chat.ts' const createInterface = vi.hoisted(() => vi.fn(() => { const reader = new EventEmitter() as EventEmitter & { close(): void } @@ -16,6 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), + // The UI seeds its label map from the registry at install; this suite only + // exercises readline terminal-mode selection, so an empty roster suffices. + agents: { list: vi.fn(() => []) }, } as unknown as Context } @@ -29,7 +32,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { describe('createStdioChat readline mode', () => { it('enables terminal editing only when both stdio streams are TTYs', async () => { - const { createStdioChat } = await import('../src/index.ts') + const { createStdioChat } = await import('../src/stdio-chat.ts') const tty = fakeRuntime(true, true) createStdioChat(fakeContext(), {}, tty) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index f72de0a1da..09fbf8987d 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -8,13 +8,16 @@ 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. + * `ctx.plugin`. The forwarded `model` reaches the pre-created agent and + * `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/ + * `resumeSessionId` route to their backends. * * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev - * plugin the in-process tier cannot import); the REAL Loader-path guard (export - * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless - * echo smoke in `examples/echo-agent`. Here we assert the composition + config + * plugin the in-process tier cannot import); the keyless echo smoke in + * `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots + * through the real Loader, while the export SHAPE is pinned by this suite's + * explicit `unwrapExports` assertion (an inject-less app would boot past a + * stray default rather than crash). Here we assert the composition + config * forwarding the unit tier can reach. */ async function mount(config: stdioAgent.Config): Promise<Context> { @@ -28,7 +31,7 @@ async function mount(config: stdioAgent.Config): Promise<Context> { 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' }) + const ctx = await mount({ model: 'mock', persona: '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() @@ -44,7 +47,8 @@ describe('dsh-stdio-agent app', () => { // 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' }) + // No persona: covers the omitted-persona forwarding branch too. + stdioAgent.apply(ctx, { model: 'mock' }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() @@ -57,7 +61,7 @@ describe('dsh-stdio-agent app', () => { // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ model: 'mock', - systemPrompt: 'hi', + persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume', resumeSessionId: 'no-such-session', }) diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts similarity index 75% rename from packages/support/ui-stdio/tests/ui-stdio.spec.ts rename to packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 7bd1fd4868..090b77ac64 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -3,9 +3,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts' +import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' /** * Unit tests for the stdio UI plugin. They drive the REAL plugin body @@ -56,11 +56,24 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, + // A minimal session stub: the UI reads only `session.header.id` (to map the + // session back to its agent id for the turn-boundary label). + session: { header: { id: `${id}-session` } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } +/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ +function makeSession(agentId: string): Session { + return { header: { id: `${agentId}-session` } } as Session +} + +/** An `assistant/chunk` session event carrying one raw stream chunk. */ +function chunkEvent(chunk: StreamChunk): SessionEvent { + return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } +} + const CONFIG: Config = { welcome: 'hi there', agent: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) { @@ -94,43 +107,92 @@ describe('createStdioChat rendering', () => { it('renders text-delta chunks verbatim', async () => { const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'hello' }) + ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) expect(out.text()).toContain('hello') }) it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => { const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'think' }) - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'more' }) - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'answer' }) + const session = makeSession('main') + ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) + ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) + ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer') }) it('ignores stream-chunk types it does not render', async () => { const { ctx, out } = await setup() const before = out.text() - const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'block-start', index: 0, blockType: 'text' }) + ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) expect(out.text()).toBe(before) }) - it('renders turn-start and turn-end markers', async () => { + it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/turn-start', agent, 3) + // agent/created populates the session-id → agent-id label map. + ctx.emit('agent/created', agent) + const session = makeSession('main') + ctx.emit('session/event', session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, + } as SessionEvent) expect(out.text()).toContain('[main turn 3] ') - ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' }) + ctx.emit('session/event', session, { + type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, + } as SessionEvent) expect(out.text()).toContain('\n> ') }) - it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => { + it('falls back to the session id as the label when no agent is mapped', async () => { + const { ctx, out } = await setup() + // No agent/created emitted, so the label map is empty — the header id shows. + ctx.emit('session/event', makeSession('orphan'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[orphan-session turn 1] ') + }) + + it('seeds labels for agents already registered before the UI installs', async () => { + // The pre-created `main` agent (and any agent surviving an HMR reload of just + // this fiber) fired its `agent/created` before the UI's listener existed, so + // the live listener alone would miss it. Seeding from `ctx.agents.list()` at + // install time is what keeps its turn header showing `[main turn N]` instead + // of the raw session id. + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = makeAgent('main') + ctx.agents.register(agent) // registered BEFORE the UI plugin below + const { runtime, out } = makeRuntime() + await ctx.plugin(Object.assign((inner: Context) => { + createStdioChat(inner, CONFIG, runtime) + }, { inject: ['agents'] })) + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 5] ') + }) + + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { + const { ctx, out } = await setup() + const session = makeSession('main') + ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) + ctx.emit('session/event', session, { + type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, + } as SessionEvent) + expect(out.text()).toContain('\x1B[2mmid\x1B[0m') + }) + + it('drops the label mapping on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' }) - ctx.emit('agent/turn-end', agent, 1, { kind: 'completed' }) - expect(out.text()).toContain('\x1B[2mmid\x1B[0m') + ctx.emit('agent/created', agent) + ctx.emit('agent/disposed', agent) + // After disposal the map no longer resolves the agent id — fall back to the + // session header id. + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main-session turn 1] ') }) it('renders tool/call and tool/result session events', async () => { @@ -171,8 +233,7 @@ describe('createStdioChat rendering', () => { it('resets dim styling when a todo/write interrupts reasoning', async () => { const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' }) + ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) ctx.emit('session/event', {} as Session, { type: 'todo/write', seq: 1, time: 0, data: { todos: [{ content: 'a task', status: 'pending' }] }, @@ -182,9 +243,8 @@ describe('createStdioChat rendering', () => { it('resets dim styling when a tool/call interrupts reasoning', async () => { const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' }) const session = {} as Session + ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) ctx.emit('session/event', session, { type: 'tool/call', seq: 1, time: 0, data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, @@ -196,7 +256,8 @@ describe('createStdioChat rendering', () => { const { ctx, out } = await setup() const before = out.text() ctx.emit('session/event', {} as Session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } }, + type: 'user/message', seq: 1, time: 0, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, } as SessionEvent) expect(out.text()).toBe(before) }) diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 58b492a549..8b76352d11 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../../../vendor/logger-console" }, @@ -31,9 +34,6 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" - }, - { - "path": "../../support/ui-stdio" } ] } diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000000..c2d34e615f --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,16 @@ +# web/ - web capability family + +The web access capability seam: an abstract web interface, search/fetch provider implementations, and the model-facing web tools. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `web/` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | +| `web-search-exa/` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-perplexity/` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-deepseek/` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) | +| `web-fetch-local/` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | +| `tool-web/` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | + +The interface lives at `web/web/`. Unlike bash/fs, the seam spans **two capabilities** (search and fetch) with potentially multiple providers each: `ctx.web` is one web-access middle layer with one provider-selection policy, one abort/error vocabulary, and one product-facing "how this harness reaches the web" config surface. Providers register **capabilities**, not tools; `tool-web` is the only owner of model-facing names, schemas, prompt guidance, and presentation. A search provider swap does not change how the model asks for a query, and a fetch implementation swap does not change how the model asks for a URL. + +See the [web capability seam RFC](../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md) for the design rationale, including why search and fetch are deliberately one seam and why `web_fetch`'s SSRF protection is deferred. diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md new file mode 100644 index 0000000000..d8a2e266a9 --- /dev/null +++ b/packages/web/tool-web/README.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-tool-web + +The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. + +Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). + +## Tools + +| Tool | Args | Behavior | +|---|---|---| +| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | +| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. | + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `search` | `true` | Register `web_search`. | +| `fetch` | `true` | Register `web_fetch`. | +| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). | + +```yaml +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +## Stable registration + +Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config. + +The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner. diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json new file mode 100644 index 0000000000..8c22afa9a8 --- /dev/null +++ b/packages/web/tool-web/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-tool-web", + "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-exa": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts new file mode 100644 index 0000000000..5f7334d952 --- /dev/null +++ b/packages/web/tool-web/src/fetch.ts @@ -0,0 +1,76 @@ +/** + * The model-facing `web_fetch` tool: retrieve the content of a specific URL. + * Execution goes through `ctx.web` — this module owns the model-facing schema, + * argument validation, and PRESENTATION (HTML→markdown, truncation formatting), + * while the fetch provider owns safe retrieval (transport, redirects, caps). + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { htmlToMarkdown } from './html.ts' + +/** Validate value constraints the schema DSL can't express. */ +export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } { + if (args.url.trim().length === 0) throw new Error('url must be a non-empty string') + if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) { + throw new Error('timeout_ms must be a positive number') + } + return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} } +} + +/** Render a fetched body to model-facing markdown text. */ +export function renderBody(body: WebFetchBody): string { + switch (body.kind) { + case 'html': + return htmlToMarkdown(body.content) + case 'text': + return body.content + /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */ + default: + return assertNever(body, 'unhandled web fetch body kind') + } +} + +/** Format a fetch result as one model-facing text block. */ +export function formatFetchOutput(result: WebFetchResult): string { + const header = `Fetched ${result.url} (HTTP ${result.statusCode})` + const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : '' + return `${header}\n\n${renderBody(result.body)}${footer}` +} + +/** Pending-call presentation: a fetch card titled by the URL. */ +export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView { + return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url } +} + +/** Register the `web_fetch` tool and its system-prompt guidance. */ +export function applyWebFetchTool(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:web_fetch', + order: 111, + text: 'Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content.', + }) + + ctx.tools.register(defineTool({ + name: 'web_fetch', + description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.', + parameters: { + url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' }, + timeout_ms: { type: 'number', description: 'Optional fetch timeout in milliseconds (capped by the provider).' }, + }, + async execute(args, exec): Promise<ContentBlock[]> { + const input = parseFetchArgs(args) + const result = await ctx.web.fetch( + { url: input.url, ...input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {} }, + exec.signal ? { signal: exec.signal } : undefined, + ) + return [{ type: 'text', text: formatFetchOutput(result) }] + }, + presentCall: presentFetchCall, + })) +} diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts new file mode 100644 index 0000000000..622be86fd5 --- /dev/null +++ b/packages/web/tool-web/src/html.ts @@ -0,0 +1,85 @@ +/** + * Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch` + * presentation. This is intentionally NOT a full HTML parser: it strips + * script/style/noscript, drops tags, decodes the common named/numeric entities, + * and collapses whitespace into a readable plain-text approximation with a few + * markdown affordances (headings, list bullets, links). A heavier converter can + * replace this without touching the seam or the tool schema. + * + * @module @deepseek-ai/dsh-tool-web/html + */ + +/** Decode the handful of HTML entities common in textual content. */ +function decodeEntities(text: string): string { + return text + .replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) { + const code = Number.parseInt(entity.slice(2), 16) + return safeFromCodePoint(code, match) + } + if (entity.startsWith('#')) { + const code = Number.parseInt(entity.slice(1), 10) + return safeFromCodePoint(code, match) + } + return NAMED_ENTITIES[entity] ?? match + }) +} + +const NAMED_ENTITIES: Record<string, string> = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', + copy: '©', reg: '®', trade: '™', hellip: '…', mdash: '—', ndash: '–', +} + +function safeFromCodePoint(code: number, fallback: string): string { + try { + return String.fromCodePoint(code) + } catch { + // An out-of-range code point (RangeError) is the only failure here; keep the + // original entity text rather than throwing out of pure presentation. + return fallback + } +} + +/** + * Convert an HTML document to a readable markdown-ish text approximation. + * Best-effort and lossy by design — fidelity is the job of a future heavier + * converter, not this fallback. + */ +export function htmlToMarkdown(html: string): string { + let text = html + // Drop non-content elements entirely (including their contents). + .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '') + .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '') + .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '') + .replace(/<!--[\s\S]*?-->/g, '') + + // Convert links to markdown before stripping tags. + text = text.replace(/<a\b[^>]*\bhref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => { + const cleanLabel = label.replace(/<[^>]+>/g, '').trim() + return cleanLabel.length > 0 ? `[${cleanLabel}](${href})` : href + }) + + // Headings → markdown hashes. + text = text.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, body: string) => { + const hashes = '#'.repeat(Number(level)) + return `\n\n${hashes} ${body.replace(/<[^>]+>/g, '').trim()}\n\n` + }) + + // List items → bullets. + text = text.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, body: string) => `\n- ${body.replace(/<[^>]+>/g, '').trim()}`) + + // Block-level breaks become paragraph breaks. + text = text + .replace(/<\/(p|div|section|article|header|footer|tr|table|ul|ol|blockquote)>/gi, '\n\n') + .replace(/<br\s*\/?>/gi, '\n') + + // Drop all remaining tags, decode entities, collapse whitespace. + text = text.replace(/<[^>]+>/g, '') + text = decodeEntities(text) + text = text + .replace(/[ \t\f\v]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + return text +} diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts new file mode 100644 index 0000000000..c0191c023b --- /dev/null +++ b/packages/web/tool-web/src/index.ts @@ -0,0 +1,73 @@ +/** + * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` + * seam. This root plugin registers the tools the product has ENABLED, composing + * the per-tool registration helpers (`applyWebSearchTool`, `applyWebFetchTool`). + * + * The package owns model-facing concerns only — tool names, JSON schemas, + * argument validation, prompt sections, result-cap constants, result formatting, + * HTML→markdown presentation. All web access goes through `ctx.web`; this + * package never imports a concrete provider package. + * + * Tool registration follows product/app ENABLEMENT, not backend availability: a + * tool stays visible even when its selected provider is missing/misconfigured, + * and execution fails with a structured `WebError` (resolved by the seam at call + * time). That keeps the model schema stable without making plugin load order, + * credential state, or HMR timing part of the model-facing contract. + * + * @module @deepseek-ai/dsh-tool-web + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' +import { applyWebFetchTool } from './fetch.ts' + +export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' +export { htmlToMarkdown } from './html.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-web' + +/** Services required by the web tool suite. */ +export const inject = ['tools', 'web', 'systemPrompt'] + +export interface Config { + /** Register `web_search`. Defaults to true. */ + search?: boolean + /** Register `web_fetch`. Defaults to true. */ + fetch?: boolean + /** Upper bound on sources returned by one `web_search` call. */ + searchMaxResults?: number +} + +export const Config: z<Config> = z.object({ + search: z.boolean().default(true), + fetch: z.boolean().default(true), + searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS), +}) + +/** The shape after schemastery applies its defaults to every field. */ +type ResolvedConfig = Required<Config> + +/** The result cap must be a positive integer (it bounds a provider's source list). */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-web: ${name} must be a positive integer`) + } +} + +/** + * Register the enabled web tools. `search`/`fetch` default to true; a product + * that wants only one disables the other in config. The tools' disposers are + * fiber-scoped (the effect-based registries clean up on dispose), so no manual + * teardown is needed. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveInteger('searchMaxResults', resolved.searchMaxResults) + if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults) + if (resolved.fetch) applyWebFetchTool(ctx) +} diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts new file mode 100644 index 0000000000..28e4e9a2e9 --- /dev/null +++ b/packages/web/tool-web/src/search.ts @@ -0,0 +1,94 @@ +/** + * The model-facing `web_search` tool: discover current information on the web. + * Execution goes through `ctx.web` — this module owns only the model-facing + * schema, argument validation, the result-count bound, and result formatting, + * never provider selection or network access. + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { WebSearchResult } from '@deepseek-ai/dsh-web' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** + * Default upper bound on returned sources (the `searchMaxResults` config). + * Owned by the consumer (not the provider or model), mirroring `dsh-tool-fs`'s + * `READ_LIMIT`. The model just asks a question; the product controls how much + * context returns. The default `8` aligns with OpenCode's Exa default. + */ +export const WEB_SEARCH_MAX_RESULTS = 8 + +/** Validate value constraints the schema DSL can't express. */ +export function parseSearchArgs(args: { query: string }): { query: string } { + if (args.query.trim().length === 0) throw new Error('query must be a non-empty string') + return { query: args.query } +} + +/** Display label for a source: its title, else its hostname. */ +function sourceLabel(url: string, title: string | undefined): string { + if (title !== undefined && title.length > 0) return title + try { + return new URL(url).hostname + } catch { + // A provider should return a valid URL, but never let a malformed one throw + // out of pure formatting — fall back to the raw string. + return url + } +} + +/** Format a search result as one model-facing text block. */ +export function formatSearchOutput(result: WebSearchResult): string { + const parts: string[] = [] + if (result.content !== undefined && result.content.length > 0) parts.push(result.content) + + if (result.sources.length > 0) { + const lines = result.sources.map((source) => { + const label = sourceLabel(source.url, source.title) + const meta: string[] = [] + if (source.snippet !== undefined && source.snippet.length > 0) meta.push(source.snippet) + if (source.publishedAt !== undefined && source.publishedAt.length > 0) meta.push(`(${source.publishedAt})`) + const suffix = meta.length > 0 ? ` — ${meta.join(' ')}` : '' + return `- [${label}](${source.url})${suffix}` + }) + parts.push(`Sources:\n${lines.join('\n')}`) + } else if (result.content === undefined || result.content.length === 0) { + parts.push('No results found.') + } + + if (result.truncated) parts.push(`(Showing the first ${result.sources.length} sources. Refine the query for more.)`) + parts.push('Cite the relevant URLs above as markdown links in your answer.') + return parts.join('\n\n') +} + +/** Pending-call presentation: a search card titled by the query. */ +export function presentSearchCall(args: { query: string }): GenericCallView { + return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } +} + +/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */ +export function applyWebSearchTool(ctx: Context, maxResults: number): void { + ctx.systemPrompt.section({ + name: 'tool:web_search', + order: 110, + text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.', + }) + + ctx.tools.register(defineTool({ + name: 'web_search', + description: 'Search the web for current information. Returns an optional summary answer and a list of source URLs.', + parameters: { + query: { type: 'string', required: true, description: 'The search query.' }, + }, + async execute(args, exec): Promise<ContentBlock[]> { + const input = parseSearchArgs(args) + const result = await ctx.web.search( + { query: input.query, maxResults }, + exec.signal ? { signal: exec.signal } : undefined, + ) + return [{ type: 'text', text: formatSearchOutput(result) }] + }, + presentCall: presentSearchCall, + })) +} diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts new file mode 100644 index 0000000000..50ae6c5624 --- /dev/null +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -0,0 +1,98 @@ +/** + * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search + * provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool + * (`dsh-tool-web`), exercised through `ctx.tools.execute()` — nothing bypasses + * the tool registry. Fetch hits a real loopback HTTP server (verifying the + * WORLD); search runs the real Exa provider over a stubbed global `fetch` (the + * network is the one boundary we mock). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { AddressInfo } from 'node:net' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import WebService from '@deepseek-ai/dsh-web' +import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' +import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' + +type Handler = (req: IncomingMessage, res: ServerResponse) => void + +let server: Server +let base: string +let handler: Handler +let ctx: Context +let fiber: Awaited<ReturnType<Context['plugin']>> + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>Hello</h1><p>World</p>') } + server = createServer((req, res) => { handler(req, res) }) + await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) + await ctx.plugin(WebFetchLocal, {}) + await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' }) + fiber = await ctx.plugin(ToolWeb) +}) + +afterEach(async () => { + await fiber.dispose() + vi.unstubAllGlobals() + await new Promise<void>(resolve => server.close(() => { resolve() })) +}) + +let counter = 0 +type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } } +function call(name: string, args: unknown): Promise<ToolResult> { + return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) +} + +describe('web_fetch integration over the real backend', () => { + it('fetches an html page and renders it to markdown', async () => { + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(false) + const text = out.content.map(b => b.text).join('') + expect(text).toContain(`Fetched ${base}`) + expect(text).toContain('# Hello') + expect(text).toContain('World') + }) + + it('reports a 404 as a result, not an error', async () => { + handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing') } + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('HTTP 404') + }) + + it('surfaces WEB_INVALID_URL as a structured tool error', async () => { + const out = await call('web_fetch', { url: 'ftp://example.com' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_INVALID_URL') + }) + + it('surfaces a blocked cross-origin redirect as WEB_REDIRECT_BLOCKED', async () => { + handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() } + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_REDIRECT_BLOCKED') + }) +}) + +describe('web_search integration over the real Exa provider', () => { + it('runs web_search end-to-end and formats the provider result', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ))) + const out = await call('web_search', { query: 'deepseek' }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)') + }) +}) diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts new file mode 100644 index 0000000000..5c47f3ce59 --- /dev/null +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -0,0 +1,49 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-tool-web. `tool-web` is a NAMESPACE + * plugin with `inject` — so a stray `export default apply` would make the cordis + * Loader's `unwrapExports` (`exports.default ?? exports`) collapse the module to + * the bare `apply` function, DROPPING `inject`. The plugin would then read + * `ctx.web` without having injected it and throw `cannot get property … without + * inject` the moment it loads (postmortem 0001). + * + * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it + * bypasses `unwrapExports`. So this test unwraps the module through the REAL + * `Loader.prototype.unwrapExports` and mounts the result over `ctx.web`, + * exercising the exact path the Loader uses. Prove the guard bites: add + * `export default apply` to `src/index.ts`, watch this go red, revert. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import WebService from '@deepseek-ai/dsh-web' +import * as toolWeb from '@deepseek-ai/dsh-tool-web' + +describe('dsh-tool-web real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in toolWeb).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolWeb) as Record<string, unknown> + expect(unwrapped).toBe(toolWeb) + expect(unwrapped.name).toBe('tool-web') + expect(unwrapped.inject).toEqual(['tools', 'web', 'systemPrompt']) + expect(typeof unwrapped.apply).toBe('function') + }) + + it('boots over ctx.web through the unwrapped module without an inject error', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, {}) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolWeb) as Parameters<Context['plugin']>[0] + // A collapsed export shape (dropped inject) would throw "without inject" here. + const fiber = await ctx.plugin(unwrapped) + expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['web_search', 'web_fetch'])) + await fiber.dispose() + }) +}) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts new file mode 100644 index 0000000000..924e0aaeb2 --- /dev/null +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import WebService from '@deepseek-ai/dsh-web' +import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' +import { + formatSearchOutput, + formatFetchOutput, + parseSearchArgs, + parseFetchArgs, + presentSearchCall, + presentFetchCall, + renderBody, + htmlToMarkdown, + WEB_SEARCH_MAX_RESULTS, +} from '@deepseek-ai/dsh-tool-web' + +const available: WebProviderStatus = { available: true } + +function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider { + return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) } +} + +/** Mount the real registry, seam, and tool-web; return an executor helper. */ +async function mountTools(opts: { + config?: ToolWeb.Config + webConfig?: ConstructorParameters<typeof WebService>[1] + search?: WebSearchProvider + fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider +} = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; call: (name: string, args: unknown) => Promise<{ isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }> }> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, opts.webConfig ?? {}) + if (opts.search) ctx.web.registerSearchProvider(opts.search) + if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider) + const fiber = await ctx.plugin(ToolWeb, opts.config ?? {}) + let counter = 0 + const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never + return { ctx, fiber, call } +} + +describe('search formatting', () => { + it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => { + const out = formatSearchOutput({ + providerId: 'p', query: 'q', content: 'an answer', truncated: false, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + expect(out).toContain('an answer') + expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)') + expect(out).toContain('[b.test](https://b.test/y)') + expect(out).toContain('Cite the relevant URLs') + }) + + it('reports no results when there is neither content nor sources', () => { + expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false })) + .toContain('No results found.') + }) + + it('renders content alone when there are no sources', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false }) + expect(out).toContain('just an answer') + expect(out).not.toContain('No results found.') + expect(out).not.toContain('Sources:') + }) + + it('notes truncation', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true }) + expect(out).toContain('Showing the first 1 sources') + }) + + it('validates the query', () => { + expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty') + expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' }) + }) + + it('presents a search call as a search-kind card titled by the query', () => { + expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' }) + }) +}) + +describe('fetch formatting', () => { + it('renders an html body to markdown text with a status header', () => { + const out = formatFetchOutput({ + providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' }, + }) + expect(out).toContain('Fetched https://a.test (HTTP 200)') + expect(out).toContain('# Title') + expect(out).toContain('Body text') + }) + + it('passes a text body through and notes truncation', () => { + const out = formatFetchOutput({ + providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true, + body: { kind: 'text', content: 'plain' }, + }) + expect(out).toContain('plain') + expect(out).toContain('Content truncated') + }) + + it('renderBody dispatches on kind', () => { + expect(renderBody({ kind: 'text', content: 'x' })).toBe('x') + expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y') + }) + + it('validates url and timeout', () => { + expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty') + expect(() => parseFetchArgs({ url: 'https://a.test', timeout_ms: -1 })).toThrow('positive') + expect(parseFetchArgs({ url: 'https://a.test', timeout_ms: 5 })).toEqual({ url: 'https://a.test', timeoutMs: 5 }) + }) + + it('presents a fetch call as a fetch-kind card titled by the url', () => { + expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ card: 'generic', title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' }) + }) +}) + +describe('htmlToMarkdown', () => { + it('drops scripts/styles, keeps text, decodes entities, converts links', () => { + const md = htmlToMarkdown('<style>.x{}</style><script>bad()</script><p>Tom & Jerry</p><a href="https://a.test">link</a>') + expect(md).not.toContain('bad()') + expect(md).not.toContain('.x{}') + expect(md).toContain('Tom & Jerry') + expect(md).toContain('[link](https://a.test)') + }) + + it('decodes numeric entities and collapses whitespace', () => { + expect(htmlToMarkdown('<p>a'b</p>')).toBe("a'b") + expect(htmlToMarkdown('<div>x</div>\n\n\n<div>y</div>')).toBe('x\n\ny') + }) + + it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => { + expect(htmlToMarkdown('<p>AB</p>')).toBe('AB') + expect(htmlToMarkdown('<p>© —</p>')).toBe('© —') + expect(htmlToMarkdown('<p>¬areal;</p>')).toBe('¬areal;') + // An out-of-range code point keeps the original entity text (fromCodePoint fallback). + expect(htmlToMarkdown('<p>�</p>')).toBe('�') + expect(htmlToMarkdown('<p>�</p>')).toBe('�') + }) + + it('renders a link with an empty label as its bare href', () => { + expect(htmlToMarkdown('<a href="https://a.test"></a>')).toBe('https://a.test') + }) + + it('converts headings and list items to markdown', () => { + expect(htmlToMarkdown('<h2>Heading</h2><p>after</p>')).toContain('## Heading') + const list = htmlToMarkdown('<ul><li>one</li><li>two</li></ul>') + expect(list).toContain('- one') + expect(list).toContain('- two') + }) + + it('falls back to the raw URL as a source label when the URL is unparseable', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] }) + expect(out).toContain('[not a url](not a url)') + }) +}) + +describe('tool-web registration', () => { + it('registers both tools by default', async () => { + const { fiber, ctx } = await mountTools() + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('web_search') + expect(names).toContain('web_fetch') + await fiber.dispose() + expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') + }) + + it('registers only enabled tools', async () => { + const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } }) + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('web_search') + expect(names).not.toContain('web_fetch') + await fiber.dispose() + }) + + it('registers only web_fetch when search is disabled', async () => { + const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } }) + const names = ctx.tools.schemas().map(s => s.name) + expect(names).not.toContain('web_search') + expect(names).toContain('web_fetch') + await fiber.dispose() + }) + + it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => { + const { fiber, ctx, call } = await mountTools() + expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search') + // No provider is registered: the schema stays visible and execution reports + // the structured unavailability instead. + const out = await call('web_search', { query: 'q' }) + expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE') + await fiber.dispose() + }) + + it('contributes prompt sections for the enabled tools', async () => { + const { fiber, ctx } = await mountTools() + const prompt = await ctx.systemPrompt.assemble() + const text = prompt.sections.map(s => s.text).join('\n') + expect(text).toContain('web_search') + expect(text).toContain('web_fetch') + await fiber.dispose() + }) +}) + +describe('tool-web execution through the real registry', () => { + it('executes web_search and formats the result', async () => { + const result: WebSearchResult = { + providerId: 'stub-search', query: 'q', content: 'answer', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }], + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('[A](https://a.test)') + await fiber.dispose() + }) + + it('surfaces a structured WebError when no provider is available', async () => { + const { fiber, call } = await mountTools() + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE') + await fiber.dispose() + }) + + it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => { + const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) + ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS') + await fiber.dispose() + }) + + it('rejects invalid arguments with a structured INVALID_ARGS error', async () => { + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) + const out = await call('web_search', { query: 123 }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('INVALID_ARGS') + await fiber.dispose() + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in ToolWeb).toBe(false) + }) + + it('executes web_fetch, forwarding timeout_ms and the abort signal to the seam', async () => { + const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {} + const fetchProvider = { + id: 'stub-fetch', + status: () => available, + fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => { + seen.request = request + seen.signal = exec?.signal + return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + }, + } + const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) + const controller = new AbortController() + const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test', timeout_ms: 1234 }, signal: controller.signal }) + expect(out.isError).toBe(false) + expect(seen.request).toEqual({ url: 'https://a.test', timeoutMs: 1234 }) + expect(seen.signal).toBe(controller.signal) + await fiber.dispose() + }) + + it('executes web_search, forwarding the abort signal to the seam', async () => { + const seen: { signal?: AbortSignal | undefined } = {} + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + } + const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const controller = new AbortController() + await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal }) + expect(seen.signal).toBe(controller.signal) + await fiber.dispose() + }) +}) + +describe('searchMaxResults is plugin config', () => { + it('forwards the default cap to the seam when unconfigured', async () => { + const seen: { maxResults?: number | undefined } = {} + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + await call('web_search', { query: 'q' }) + expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS) + await fiber.dispose() + }) + + it('forwards a configured cap to the seam, which enforces it', async () => { + const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` })) + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }), + } + const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(false) + const body = out.content.map(b => b.text).join('') + expect(body).toContain('https://s1.test') + expect(body).not.toContain('https://s2.test') + expect(body).toContain('Showing the first 2 sources.') + await fiber.dispose() + }) + + it.each([ + ['zero', 0], + ['negative', -3], + ['fractional', 1.5], + ])('rejects a %s searchMaxResults at load', async (_label, value) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, {}) + await expect(ctx.plugin(ToolWeb, { searchMaxResults: value })) + .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/) + }) +}) diff --git a/packages/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json new file mode 100644 index 0000000000..463a18dee9 --- /dev/null +++ b/packages/web/tool-web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../web" } + ] +} diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md new file mode 100644 index 0000000000..58db557581 --- /dev/null +++ b/packages/web/web-fetch-local/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-web-fetch-local + +An anonymous public HTTP(S) `WebFetchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It retrieves a concrete URL and returns a status code plus bounded decoded content. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`). + +## Responsibility split + +The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. + +## Transport hygiene + +- Accepts only `http:` and `https:` URLs; rejects credentials in URLs (`WEB_BLOCKED_URL`) and over-long/malformed URLs (`WEB_INVALID_URL`). +- Enforces a max URL length, response byte cap (`WEB_FETCH_TOO_LARGE`), decoded body character cap, timeout (`WEB_FETCH_TIMEOUT`), and redirect hop cap. +- Propagates the caller's abort signal (`WEB_ABORTED`) into the network request and the streaming read. +- Follows only **same-origin** redirects; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (the model of Claude Code's WebFetch). +- Sends an explicit product `User-Agent`, never a browser disguise. +- Rejects unsupported (e.g. binary) content types with `WEB_UNSUPPORTED_CONTENT_TYPE`. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `maxUrlLength` | `2048` | Maximum accepted request URL length. | +| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | +| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | +| `timeoutMs` | `30_000` | Default fetch timeout. | +| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. | +| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | +| `userAgent` | `deepseek-harness/…` | `User-Agent` header. | + +The numeric limits are validated at plugin construction: every cap except `maxRedirects` must be a positive finite number, and `maxRedirects` must be a non-negative integer. An invalid value throws rather than silently constructing a provider with nonsensical limits. + +## Security note + +SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate and per-hop re-validation) is **deferred** — see the [web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets. diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json new file mode 100644 index 0000000000..8d9a599a52 --- /dev/null +++ b/packages/web/web-fetch-local/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-web-fetch-local", + "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts new file mode 100644 index 0000000000..7eb614f39a --- /dev/null +++ b/packages/web/web-fetch-local/src/index.ts @@ -0,0 +1,97 @@ +/** + * `@deepseek-ai/dsh-web-fetch-local`: registers an anonymous public HTTP(S) + * `WebFetchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's fetch registry, like the + * search providers register into the search registry. + * + * @module @deepseek-ai/dsh-web-fetch-local + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { LocalFetchProvider } from './provider.ts' +import type { LocalFetchLimits } from './provider.ts' + +export { + LOCAL_FETCH_PROVIDER_ID, + LocalFetchProvider, +} from './provider.ts' +export type { LocalFetchLimits } from './provider.ts' +export { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' +export type { FetchableKind } from './policy.ts' + +/** Default `User-Agent`: an explicit product agent, never a browser disguise. */ +export const DEFAULT_USER_AGENT = 'deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-fetch-local' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Maximum accepted request URL length. */ + maxUrlLength?: number + /** Maximum response body size in bytes. */ + maxResponseBytes?: number + /** Maximum decoded body length in characters. */ + maxBodyChars?: number + /** Default fetch timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for a per-request timeout override. */ + maxTimeoutMs?: number + /** Maximum number of same-origin redirect hops to follow. */ + maxRedirects?: number + /** `User-Agent` header sent on every request. */ + userAgent?: string +} + +export const Config: z<Config> = z.object({ + maxUrlLength: z.number().default(2048), + maxResponseBytes: z.number().default(5_000_000), + maxBodyChars: z.number().default(100_000), + timeoutMs: z.number().default(30_000), + maxTimeoutMs: z.number().default(120_000), + maxRedirects: z.number().default(5), + userAgent: z.string().default(DEFAULT_USER_AGENT), +}) + +/** The shape after schemastery applies its defaults to every field. */ +type ResolvedConfig = Required<Config> + +/** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`web-fetch-local: ${name} must be a positive finite number`) + } +} + +/** The redirect hop cap must be a non-negative integer (0 follows no redirects). */ +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`web-fetch-local: ${name} must be a non-negative integer`) + } +} + +/** Register the local HTTP(S) fetch provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) + assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) + assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) + assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) + const limits: LocalFetchLimits = { + maxUrlLength: resolved.maxUrlLength, + maxResponseBytes: resolved.maxResponseBytes, + maxBodyChars: resolved.maxBodyChars, + timeoutMs: resolved.timeoutMs, + maxTimeoutMs: resolved.maxTimeoutMs, + maxRedirects: resolved.maxRedirects, + userAgent: resolved.userAgent, + } + ctx.web.registerFetchProvider(new LocalFetchProvider(limits)) +} diff --git a/packages/web/web-fetch-local/src/policy.ts b/packages/web/web-fetch-local/src/policy.ts new file mode 100644 index 0000000000..7a76bd1af1 --- /dev/null +++ b/packages/web/web-fetch-local/src/policy.ts @@ -0,0 +1,85 @@ +/** + * URL validation and content-type classification for the local HTTP(S) fetch + * provider — the pure, network-free half. The provider's `fetch()` composes + * these with transport (redirect following, byte caps, decoding). + * + * @module @deepseek-ai/dsh-web-fetch-local/policy + */ + +import { WebError } from '@deepseek-ai/dsh-web' + +/** The body kinds this provider decodes. */ +export type FetchableKind = 'html' | 'text' + +/** + * Validate a request URL against the basic transport hygiene the provider + * enforces before any network access: http(s) only, no embedded credentials, + * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise. + * (SSRF / private-network blocking is deferred — see the package RFC.) + */ +export function validateFetchUrl(input: string, maxUrlLength: number): URL { + if (input.length > maxUrlLength) { + throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL') + } + let url: URL + try { + url = new URL(input) + } catch (error: unknown) { + throw new WebError(`invalid URL: ${input}`, 'WEB_INVALID_URL', { cause: error }) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, 'WEB_INVALID_URL') + } + if (url.username.length > 0 || url.password.length > 0) { + throw new WebError('credentials in URLs are not allowed', 'WEB_BLOCKED_URL') + } + return url +} + +/** + * Two URLs are same-origin when scheme, hostname, and port match. A redirect + * that crosses origins is refused so each new origin requires a fresh tool call + * (and thus a fresh provider/permission decision). + */ +export function isSameOrigin(a: URL, b: URL): boolean { + return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port +} + +/** + * Classify a response `Content-Type` into a decodable body kind, or `undefined` + * for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml` + * are `html`; other `text/*` plus a few structured text types are `text`. + */ +export function classifyContentType(contentType: string | null): FetchableKind | undefined { + const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase() + if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html' + if (mime.startsWith('text/')) return 'text' + if (mime === 'application/json' || mime === 'application/xml' || mime.endsWith('+json') || mime.endsWith('+xml')) return 'text' + return undefined +} + +/** + * Extract the `charset` parameter from a response `Content-Type`, lower-cased, + * or `undefined` when absent. The provider feeds this label to `TextDecoder` + * so a non-UTF-8 response is decoded with its declared encoding rather than + * silently mangled into replacement characters. + */ +export function parseCharset(contentType: string | null): string | undefined { + const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '') + return match?.[1]?.trim().toLowerCase() +} + +/** + * Build a `TextDecoder` for the declared charset, falling back to UTF-8 when + * none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when + * the label is present but not a charset `TextDecoder` recognizes — better to + * fail loudly than return mojibake. + */ +export function decoderForCharset(charset: string | undefined): TextDecoder { + if (charset === undefined) return new TextDecoder('utf-8') + try { + return new TextDecoder(charset) + } catch (error: unknown) { + throw new WebError(`unsupported charset "${charset}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE', { cause: error }) + } +} diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts new file mode 100644 index 0000000000..29b183b710 --- /dev/null +++ b/packages/web/web-fetch-local/src/provider.ts @@ -0,0 +1,278 @@ +/** + * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public + * HTTP(S) URL with the platform-native `fetch` (Node 24) and returns a status + * code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL + * validation, redirect policy, timeout, abort, byte caps, charset decoding, + * content-type classification, binary rejection — but NOT presentation + * (HTML→markdown lives in `@deepseek-ai/dsh-tool-web`). + * + * Redirects are followed manually (`redirect: 'manual'`) so the provider can + * enforce a same-origin-only policy: a cross-origin redirect is refused with + * `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (Claude Code's WebFetch + * uses the same model). It does NOT carry browser cookies, editor/git + * credentials, or implicit access to private services. + * + * SSRF / private-network protection is DEFERRED (see the package RFC); until it + * lands this provider is an SSRF primitive and must not be enabled where it can + * reach sensitive internal targets. + * + * @module @deepseek-ai/dsh-web-fetch-local/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' +import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' + +/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ +export interface LocalFetchLimits { + /** Maximum accepted request URL length. */ + maxUrlLength: number + /** Maximum response body size in bytes (read is aborted past this). */ + maxResponseBytes: number + /** Maximum decoded body length in characters (truncated past this). */ + maxBodyChars: number + /** Default fetch timeout in milliseconds. */ + timeoutMs: number + /** Upper bound for a per-request timeout override. */ + maxTimeoutMs: number + /** Maximum number of (same-origin) redirect hops to follow. */ + maxRedirects: number + /** `User-Agent` header sent on every request. */ + userAgent: string +} + +/** Stable id this provider registers under. */ +export const LOCAL_FETCH_PROVIDER_ID = 'local-http' + +/** The anonymous public HTTP(S) fetch provider. */ +export class LocalFetchProvider implements WebFetchProvider { + readonly id = LOCAL_FETCH_PROVIDER_ID + + constructor(private readonly limits: LocalFetchLimits) {} + + /** No credentials to check — an anonymous public fetcher is always usable. */ + status(): WebProviderStatus { + return { available: true } + } + + async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> { + const timeoutMs = request.timeoutMs !== undefined + ? Math.min(request.timeoutMs, this.limits.maxTimeoutMs) + : this.limits.timeoutMs + + // One controller drives both the caller's abort and our own timeout, so the + // network request and the streaming read both stop on either. + const controller = new AbortController() + const onAbort = (): void => { controller.abort() } + if (exec?.signal !== undefined) { + if (exec.signal.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') + exec.signal.addEventListener('abort', onAbort, { once: true }) + } + const timer = setTimeout(() => { controller.abort(new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT')) }, timeoutMs) + + try { + return await this.followAndRead(request.url, controller) + } finally { + clearTimeout(timer) + if (exec?.signal !== undefined) exec.signal.removeEventListener('abort', onAbort) + } + } + + /** Follow same-origin redirects up to the hop cap, then read the final response. */ + private async followAndRead(initialUrl: string, controller: AbortController): Promise<WebFetchResult> { + let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) + let redirectsFollowed = 0 + + for (;;) { + const response = await this.requestOnce(currentUrl, controller) + + if (isRedirectStatus(response.status)) { + // The redirect budget is enforced BEFORE this hop's target is resolved + // or origin-checked, so `maxRedirects: N` follows at most N redirects + // exactly: the (N+1)th redirect is refused as "exceeded" regardless of + // where it points (a same-origin/cross-origin distinction on a hop we + // are not allowed to follow would be the wrong diagnosis). + if (redirectsFollowed >= this.limits.maxRedirects) { + await response.body?.cancel() + throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') + } + const location = response.headers.get('location') + if (location === null) { + // A redirect status with no Location is not a usable resource. Cancel + // the (possibly streaming) body before throwing so no socket leaks. + await response.body?.cancel() + throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') + } + const target = resolveRedirect(location, currentUrl) + // Re-validate the target against the same transport hygiene a direct + // request gets: a redirect must not be a back door to a credentialed, + // non-http(s), or over-long URL that validateFetchUrl would reject. A + // rejection here must still cancel the body first (see below). + let validatedTarget: URL + try { + validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) + if (!isSameOrigin(validatedTarget, currentUrl)) { + throw new WebError( + `cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, + 'WEB_REDIRECT_BLOCKED', + ) + } + } catch (error: unknown) { + await response.body?.cancel() + throw error + } + await response.body?.cancel() + currentUrl = validatedTarget + redirectsFollowed++ + continue + } + + return await this.readBody(response, currentUrl, controller.signal) + } + } + + private async requestOnce(url: URL, controller: AbortController): Promise<Response> { + try { + return await fetch(url, { + method: 'GET', + redirect: 'manual', + headers: { 'user-agent': this.limits.userAgent, 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8' }, + signal: controller.signal, + }) + } catch (error: unknown) { + throw translateAbortOrNetwork(error, controller.signal) + } + } + + /** Read, byte-cap, classify, and decode the final response body. */ + private async readBody(response: Response, finalUrl: URL, signal: AbortSignal): Promise<WebFetchResult> { + const contentType = response.headers.get('content-type') + const kind = classifyContentType(contentType) + if (kind === undefined) { + await response.body?.cancel() + throw new WebError(`unsupported content type "${contentType ?? 'unknown'}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE') + } + + // Resolve the decoder BEFORE reading the body so an unsupported charset + // fails without consuming the stream — but cancel the body on that failure + // so the socket does not leak (matching the unsupported-content-type path). + let decoder: TextDecoder + try { + decoder = decoderForCharset(parseCharset(contentType)) + } catch (error: unknown) { + await response.body?.cancel() + throw error + } + const { bytes, truncatedByBytes } = await this.readCapped(response, signal) + const decoded = decoder.decode(bytes) + const truncatedByChars = decoded.length > this.limits.maxBodyChars + const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded + const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } + + return { + providerId: this.id, + url: finalUrl.toString(), + statusCode: response.status, + body, + truncated: truncatedByBytes || truncatedByChars, + } + } + + /** + * Read the response stream up to `maxResponseBytes`. A `Content-Length` over + * the cap rejects immediately with `WEB_FETCH_TOO_LARGE`; a stream that grows + * past the cap is cut short (`truncatedByBytes`) rather than rejected, so a + * server that under-reports still yields a bounded usable body. + */ + private async readCapped(response: Response, signal: AbortSignal): Promise<{ bytes: Uint8Array; truncatedByBytes: boolean }> { + const declared = response.headers.get('content-length') + if (declared !== null) { + const length = Number(declared) + if (Number.isFinite(length) && length > this.limits.maxResponseBytes) { + await response.body?.cancel() + throw new WebError(`response exceeds the maximum of ${this.limits.maxResponseBytes} bytes`, 'WEB_FETCH_TOO_LARGE') + } + } + + /* v8 ignore next -- a 2xx Response from fetch always exposes a body stream; the null guard is defensive. */ + if (response.body === null) return { bytes: new Uint8Array(0), truncatedByBytes: false } + + const chunks: Uint8Array[] = [] + let total = 0 + let truncatedByBytes = false + const reader = response.body.getReader() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + const remaining = this.limits.maxResponseBytes - total + // Only DROPPED bytes count as truncation: a chunk that exactly fills the + // remaining capacity keeps all its bytes and we read on to observe EOF, + // so an exactly-at-cap body is not falsely flagged truncated. + if (value.byteLength > remaining) { + chunks.push(value.subarray(0, remaining)) + total += remaining + truncatedByBytes = true + break + } + chunks.push(value) + total += value.byteLength + } + } catch (error: unknown) { + /* v8 ignore next -- mid-stream read fault needs a network drop after headers; translate path covered by request-phase tests. */ + throw translateAbortOrNetwork(error, signal) + } finally { + /* v8 ignore next 4 -- cancel() after a completed/broken read settles without rejecting; unobserved best-effort cleanup. */ + await reader.cancel().catch(() => { + // Cancel after a successful read (or after we broke past the cap) is + // best-effort cleanup; the bytes we need are already collected. + }) + } + + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return { bytes, truncatedByBytes } + } +} + +/** HTTP redirect status codes that carry a `Location`. */ +function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308 +} + +/** Resolve a (possibly relative) `Location` against the current URL. */ +function resolveRedirect(location: string, base: URL): URL { + try { + return new URL(location, base) + } catch (error: unknown) { + /* v8 ignore next 2 -- URL resolution against a valid absolute base effectively never throws; defensive guard. */ + throw new WebError(`invalid redirect Location "${location}"`, 'WEB_PROVIDER_ERROR', { cause: error }) + } +} + +/** + * Translate a thrown fetch/stream error into a `WebError`. Our own + * `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other + * already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`, + * UNLESS the abort was our timeout — the body-read reader surfaces a generic + * `AbortError` rather than the abort reason, so we recover the timeout's + * `WebError` from `signal.reason`; anything else is a transport/network failure + * (`WEB_PROVIDER_ERROR`). + */ +function translateAbortOrNetwork(error: unknown, signal?: AbortSignal): WebError { + if (error instanceof WebError) return error + if (error instanceof DOMException && error.name === 'AbortError') { + // A timeout abort carries its WebError as the signal reason; honor the + // WEB_FETCH_TIMEOUT contract instead of reporting a generic cancellation. + // (Node rejects WITH the reason — the WebError branch above — so this only + // fires on a runtime that surfaces a bare AbortError while reason is set.) + /* v8 ignore next */ + if (signal?.reason instanceof WebError) return signal.reason + return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error }) + } + return new WebError(`web fetch failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) +} diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts new file mode 100644 index 0000000000..7d4c4e683b --- /dev/null +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { AddressInfo } from 'node:net' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local' +import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local' +import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-local' + +const limits: LocalFetchLimits = { + maxUrlLength: 2048, + maxResponseBytes: 5_000_000, + maxBodyChars: 100_000, + timeoutMs: 5_000, + maxTimeoutMs: 10_000, + maxRedirects: 5, + userAgent: 'test-agent/1.0', +} + +type Handler = (req: IncomingMessage, res: ServerResponse) => void + +let server: Server +let base: string +let handler: Handler + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') } + server = createServer((req, res) => { handler(req, res) }) + await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + base = `http://127.0.0.1:${port}` +}) + +afterEach(async () => { + vi.unstubAllGlobals() + await new Promise<void>(resolve => server.close(() => { resolve() })) +}) + +function provider(overrides: Partial<LocalFetchLimits> = {}): LocalFetchProvider { + return new LocalFetchProvider({ ...limits, ...overrides }) +} + +describe('policy helpers', () => { + it('validates scheme, credentials, and length', () => { + expect(validateFetchUrl('https://example.com/x', 2048).hostname).toBe('example.com') + expect(() => validateFetchUrl('ftp://example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + expect(() => validateFetchUrl('not a url', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + expect(() => validateFetchUrl('https://user:pass@example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + expect(() => validateFetchUrl(`https://example.com/${'a'.repeat(3000)}`, 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + }) + + it('classifies content types', () => { + expect(classifyContentType('text/html; charset=utf-8')).toBe('html') + expect(classifyContentType('application/xhtml+xml')).toBe('html') + expect(classifyContentType('text/plain')).toBe('text') + expect(classifyContentType('application/json')).toBe('text') + expect(classifyContentType('image/png')).toBeUndefined() + expect(classifyContentType(null)).toBeUndefined() + }) + + it('compares origins', () => { + expect(isSameOrigin(new URL('https://a.com/x'), new URL('https://a.com/y'))).toBe(true) + expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false) + expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false) + }) + + it('parses the charset parameter', () => { + expect(parseCharset('text/html; charset=UTF-8')).toBe('utf-8') + expect(parseCharset('text/plain; charset="iso-8859-1"')).toBe('iso-8859-1') + expect(parseCharset('text/plain')).toBeUndefined() + expect(parseCharset(null)).toBeUndefined() + }) + + it('builds a decoder for a charset and defaults to UTF-8', () => { + expect(decoderForCharset(undefined).encoding).toBe('utf-8') + expect(decoderForCharset('iso-8859-1').encoding).toBe('windows-1252') + expect(() => decoderForCharset('not-a-charset')).toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) +}) + +describe('LocalFetchProvider success', () => { + it('fetches a text body', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } + const result = await provider().fetch({ url: base }) + expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID) + expect(result.statusCode).toBe(200) + expect(result.body).toEqual({ kind: 'text', content: 'hello world' }) + expect(result.truncated).toBe(false) + }) + + it('fetches an html body and classifies it as html', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>hi</h1>') } + const result = await provider().fetch({ url: base }) + expect(result.body).toEqual({ kind: 'html', content: '<h1>hi</h1>' }) + }) + + it('sends the configured user agent', async () => { + let seen: string | undefined + handler = (req, res) => { seen = req.headers['user-agent']; res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') } + await provider().fetch({ url: base }) + expect(seen).toBe('test-agent/1.0') + }) + + it('returns a non-2xx response as a result, not an error', async () => { + handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('nope') } + const result = await provider().fetch({ url: base }) + expect(result.statusCode).toBe(404) + expect(result.body).toEqual({ kind: 'text', content: 'nope' }) + }) +}) + +describe('LocalFetchProvider caps', () => { + it('rejects an over-cap Content-Length with WEB_FETCH_TOO_LARGE', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '999999' }); res.end('x'.repeat(999999)) } + await expect(provider({ maxResponseBytes: 10 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TOO_LARGE' })) + }) + + it('truncates a stream that grows past the byte cap', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') } + const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base }) + expect(result.body.content).toBe('abcd') + expect(result.truncated).toBe(true) + }) + + it('does not flag a body that exactly fills the byte cap as truncated', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcd') } + const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base }) + expect(result.body.content).toBe('abcd') + expect(result.truncated).toBe(false) + }) + + it('truncates a decoded body past the character cap', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') } + const result = await provider({ maxBodyChars: 3 }).fetch({ url: base }) + expect(result.body.content).toBe('abc') + expect(result.truncated).toBe(true) + }) + + it('rejects an unsupported content type', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'image/png' }); res.end('binary') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) + + it('rejects a response with no content type at all', async () => { + handler = (_req, res) => { res.writeHead(200); res.end('no type') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) + + it('accepts a declared content-length within the cap', async () => { + handler = (_req, res) => { const body = 'sized'; res.writeHead(200, { 'content-type': 'text/plain', 'content-length': String(body.length) }); res.end(body) } + const result = await provider().fetch({ url: base }) + expect(result.body.content).toBe('sized') + }) + + it('decodes a non-UTF-8 declared charset', async () => { + // 0xE9 is "é" in ISO-8859-1; decoded as UTF-8 it would be a replacement char. + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=iso-8859-1' }); res.end(Buffer.from([0x63, 0x61, 0x66, 0xE9])) } + const result = await provider().fetch({ url: base }) + expect(result.body.content).toBe('café') + }) + + it('rejects an unsupported declared charset', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=not-a-charset' }); res.end('x') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) +}) + +describe('LocalFetchProvider redirects', () => { + it('follows a same-origin redirect and reports the final URL', async () => { + handler = (req, res) => { + if (req.url === '/start') { res.writeHead(302, { location: '/end' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('arrived') } + } + const result = await provider().fetch({ url: `${base}/start` }) + expect(result.body.content).toBe('arrived') + expect(result.url).toBe(`${base}/end`) + }) + + it('blocks a cross-origin redirect with WEB_REDIRECT_BLOCKED', async () => { + handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + }) + + it('re-validates a redirect target, rejecting same-origin credentials in the Location', async () => { + const { port } = server.address() as AddressInfo + handler = (_req, res) => { res.writeHead(302, { location: `http://user:pass@127.0.0.1:${port}/` }); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + }) + + it('rejects exceeding the redirect hop cap', async () => { + handler = (req, res) => { + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + res.writeHead(302, { location: `/?n=${n + 1}` }) + res.end() + } + await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + }) + + it('follows exactly maxRedirects hops: a chain landing on the Nth redirect succeeds', async () => { + // maxRedirects: 2 → /?n=0 → /?n=1 → /?n=2(200). Exactly 2 redirects + 1 + // final = 3 requests; the cap is inclusive of the landing request. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + if (n >= 2) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') } + else { res.writeHead(302, { location: `/?n=${n + 1}` }); res.end() } + } + const result = await provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }) + expect(result.body.content).toBe('landed') + expect(requests).toBe(3) + }) + + it('makes exactly maxRedirects+1 requests before blocking an over-long chain', async () => { + // maxRedirects: 2 on an infinite chain: requests at n=0,1,2 (the 3rd is the + // over-limit redirect, refused before its Location is followed) = 3 total. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + res.writeHead(302, { location: `/?n=${n + 1}` }) + res.end() + } + await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 2 redirects' })) + expect(requests).toBe(3) + }) + + it('reports an over-limit redirect as "exceeded", not cross-origin, even when the over-limit hop points cross-origin', async () => { + // The redirect budget is checked BEFORE the over-limit hop's target is + // origin-validated, so the diagnosis is "exceeded", not "cross-origin". + handler = (req, res) => { + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + const location = n === 0 ? '/?n=1' : 'https://example.com/' + res.writeHead(302, { location }) + res.end() + } + await expect(provider({ maxRedirects: 1 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 1 redirects' })) + }) + + it('maxRedirects: 0 follows no redirect but still fetches a direct 200', async () => { + handler = (req, res) => { + if (req.url === '/r') { res.writeHead(302, { location: '/done' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('direct') } + } + await expect(provider({ maxRedirects: 0 }).fetch({ url: `${base}/r` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + const direct = await provider({ maxRedirects: 0 }).fetch({ url: `${base}/done` }) + expect(direct.body.content).toBe('direct') + }) + + it('treats a redirect without a Location header as a provider error', async () => { + handler = (_req, res) => { res.writeHead(302); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('follows a relative same-origin redirect', async () => { + handler = (req, res) => { + if (req.url === '/a') { res.writeHead(301, { location: 'b' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') } + } + const result = await provider().fetch({ url: `${base}/a` }) + expect(result.body.content).toBe('landed') + }) +}) + +describe('LocalFetchProvider invalid URLs and abort', () => { + it('rejects a non-http scheme before any network access', async () => { + await expect(provider().fetch({ url: 'ftp://example.com' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + }) + + it('rejects credentials in the URL', async () => { + await expect(provider().fetch({ url: 'http://user:pass@127.0.0.1/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + }) + + it('honors a pre-aborted signal', async () => { + const controller = new AbortController() + controller.abort() + await expect(provider().fetch({ url: base }, { signal: controller.signal })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('aborts an in-flight fetch via the signal', async () => { + handler = (_req, _res) => { /* never responds */ } + const controller = new AbortController() + const promise = provider().fetch({ url: base }, { signal: controller.signal }) + controller.abort() + await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('times out a slow response with WEB_FETCH_TIMEOUT', async () => { + handler = (_req, _res) => { /* never responds */ } + await expect(provider({ timeoutMs: 50 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' })) + }) + + it('classifies a timeout DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED', async () => { + // Promise body that resolves headers (so fetch() returns) but a content-length + // that outlasts the bytes sent, so readCapped()'s reader awaits more and the + // timeout fires mid-read — the reader then surfaces a generic AbortError that + // must still be recovered as the timeout reason via signal.reason. + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' }) + res.write('partial') + // never send the remaining bytes nor end the response + } + await expect(provider({ timeoutMs: 80 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' })) + }) + + it('maps a connection failure to WEB_PROVIDER_ERROR', async () => { + // Port 1 on loopback is not listening: a real connection failure (not abort). + await expect(provider().fetch({ url: 'http://127.0.0.1:1/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('caps the per-request timeout at maxTimeoutMs', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') } + const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 }) + expect(result.statusCode).toBe(200) + }) +}) + +describe('LocalFetchProvider body cancellation on error paths', () => { + /** A fake Response whose body.cancel is observable. */ + type FakeInit = { status: number; headers: Record<string, string>; location?: string } + function fakeResponse(init: FakeInit): { response: Response; cancelled: () => boolean } { + let cancelled = false + const headers = new Headers(init.headers) + if (init.location !== undefined) headers.set('location', init.location) + const response = { + status: init.status, + headers, + body: { cancel: () => { cancelled = true; return Promise.resolve() } }, + } as unknown as Response + return { response, cancelled: () => cancelled } + } + + it('cancels the body when a cross-origin redirect is blocked', async () => { + const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + expect(cancelled()).toBe(true) + }) + + it('cancels the body when an unsupported charset is rejected', async () => { + const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + expect(cancelled()).toBe(true) + }) + + it('cancels the body when a redirect has no Location header', async () => { + const { response, cancelled } = fakeResponse({ status: 302, headers: {} }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + expect(cancelled()).toBe(true) + }) +}) + +describe('web-fetch-local plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + const fiber = await ctx.plugin(fetchPlugin, {}) + await expect(ctx.web.fetch({ url: `${base}/` })) + .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + await fiber.dispose() + await expect(ctx.web.fetch({ url: `${base}/` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in fetchPlugin).toBe(false) + }) + + it('rejects a non-positive resource limit at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxResponseBytes: -1 })) + .rejects.toThrow(/maxResponseBytes must be a positive finite number/) + }) + + it('rejects a zero timeout at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { timeoutMs: 0 })) + .rejects.toThrow(/timeoutMs must be a positive finite number/) + }) + + it('rejects a fractional redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: 1.5 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('rejects a negative redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: -1 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('accepts maxRedirects: 0 (follow no redirects) as valid config', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) + await expect(ctx.web.fetch({ url: `${base}/` })) + .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + await fiber.dispose() + }) +}) diff --git a/packages/web/web-fetch-local/tsconfig.json b/packages/web/web-fetch-local/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-fetch-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md new file mode 100644 index 0000000000..41b000d26a --- /dev/null +++ b/packages/web/web-search-deepseek/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-web-search-deepseek + +A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. + +## How it differs from a dedicated search endpoint + +Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead this provider issues a **full Messages model call** carrying the `web_search` server tool, so one search costs a complete model turn in latency and tokens — heavier than a pure retrieval endpoint. DeepSeek runs the search server-side and returns **structured** `web_search_tool_result` blocks; the provider parses those blocks and **never scrapes URLs out of model prose**. + +**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable. + +It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. | +| `model` | `deepseek-v4-flash` | Anthropic-format model name. | +| `apiVersion` | `2023-06-01` | `anthropic-version` header value. | +| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | +| `maxUses` | `5` | Positive-integer maximum `web_search` server-tool uses per request. | + +```yaml +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL +``` + +## Mapping + +DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, `publishedAt` ← `page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json new file mode 100644 index 0000000000..617e9f2768 --- /dev/null +++ b/packages/web/web-search-deepseek/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-web-search-deepseek", + "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts new file mode 100644 index 0000000000..c993fa8808 --- /dev/null +++ b/packages/web/web-search-deepseek/src/index.ts @@ -0,0 +1,83 @@ +/** + * `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed + * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's provider registry, like + * `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`. + * + * The provider talks to DeepSeek's Anthropic-compatible Messages API with the + * native `web_search_20250305` server tool. It reuses `$DEEPSEEK_API_KEY` (no + * new secret) but NOT `$DEEPSEEK_BASE_URL` — the search endpoint is the + * Anthropic-compatible base, distinct from the chat-completions base the LLM + * adapter uses. + * + * @module @deepseek-ai/dsh-web-search-deepseek + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, +} from './provider.ts' + +export { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, + DEEPSEEK_PROVIDER_ID, + citationSnippets, + mapAnthropicResponse, +} from './provider.ts' +export type { DeepSeekSearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-deepseek' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ + apiKey?: string + /** Anthropic-compatible endpoint base; `/messages` is appended. */ + baseURL?: string + /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ + model?: string + /** `anthropic-version` header value. Defaults to `2023-06-01`. */ + apiVersion?: string + /** Upper bound on generated tokens for the Messages request. Defaults to 4096. */ + maxTokens?: number + /** Maximum `web_search` server-tool uses per request. Defaults to 5. */ + maxUses?: number +} + +export const Config: z<Config> = z.object({ + apiKey: z.string(), + baseURL: z.string(), + model: z.string(), + apiVersion: z.string(), + maxTokens: z.number().step(1).min(1), + maxUses: z.number().step(1).min(1), +}) + +/** Register the DeepSeek search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS + const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES + ctx.web.registerSearchProvider(new DeepSeekSearchProvider({ + apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '', + baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, + model: config.model ?? DEEPSEEK_DEFAULT_MODEL, + apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, + maxTokens, + maxUses, + })) +} diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts new file mode 100644 index 0000000000..40566b4f75 --- /dev/null +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -0,0 +1,223 @@ +/** + * `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's + * Anthropic-compatible Messages API with the native `web_search_20250305` server + * tool enabled. + * + * Unlike a dedicated search endpoint (Exa's `POST /search`, Perplexity's + * `/chat/completions`), this issues a FULL Messages model call carrying a server + * tool, so a search costs a complete model turn in latency and tokens. In return + * DeepSeek runs the search server-side and returns STRUCTURED + * `web_search_tool_result` blocks — this provider parses those blocks and never + * scrapes URLs out of model prose. Strict mode: if the response carries no + * `web_search_tool_result` block (native search did not trigger), it throws + * `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. + * The Anthropic wire shape is a provider-private detail and does NOT make this + * provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-deepseek/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { + AnthropicError, + AnthropicResponse, + ContentBlock, + TextBlock, + WebSearchToolResultBlock, +} from './types.ts' + +/** Stable id this provider registers under. */ +export const DEEPSEEK_PROVIDER_ID = 'deepseek' + +/** + * Default endpoint: DeepSeek's Anthropic-compatible surface, `/v1` included + * (`/messages` is appended). This is NOT the chat-completions base + * (`https://api.deepseek.com`) `@deepseek-ai/dsh-llm-deepseek` uses, so this + * provider does NOT reuse `$DEEPSEEK_BASE_URL` — only the API key is shared. + */ +export const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic/v1' + +/** Default Anthropic-format model name (aligned with the repo's DeepSeek model vocabulary). */ +export const DEEPSEEK_DEFAULT_MODEL = 'deepseek-v4-flash' + +/** Default `anthropic-version` header value. */ +export const DEEPSEEK_DEFAULT_API_VERSION = '2023-06-01' + +/** Default upper bound on generated tokens for the Messages request. */ +export const DEEPSEEK_DEFAULT_MAX_TOKENS = 4096 + +/** Default maximum `web_search` server-tool uses per request. */ +export const DEEPSEEK_DEFAULT_MAX_USES = 5 + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface DeepSeekSearchProviderOptions { + /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/messages` is appended. */ + baseURL: string + /** Anthropic-format model name. */ + model: string + /** `anthropic-version` header value. */ + apiVersion: string + /** Upper bound on generated tokens for the Messages request. */ + maxTokens: number + /** Maximum `web_search` server-tool uses per request. */ + maxUses: number +} + +/** + * Build a `url → cited_text` map from every `text` block's `citations[]`. This + * is the snippet surface: Anthropic `web_search_result` items carry + * `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives + * in a separate `text` block's citation, keyed by `url` (first occurrence wins). + */ +export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, string> { + const map = new Map<string, string>() + for (const block of blocks) { + if (block.type !== 'text') continue + for (const cite of (block as TextBlock).citations ?? []) { + if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) { + map.set(cite.url, cite.cited_text) + } + } + } + return map +} + +/** + * Map a DeepSeek Anthropic Messages response to a normalized search result. + * Walks `web_search_tool_result` blocks for citeable `web_search_result` items, + * joins each to its citation excerpt as `snippet`, and dedupes by `url` (a + * `max_uses > 1` request can surface the same URL across searches). The seam + * owns the final `maxResults` truncation, so `truncated` is always `false` here. + * + * Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result` + * block is present — native search did not trigger, and prose-scraping is not a + * fallback. + */ +export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult { + const blocks = response.content ?? [] + const resultBlocks = blocks.filter( + (block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result', + ) + if (resultBlocks.length === 0) { + throw new WebError( + 'DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search', + 'WEB_PROVIDER_ERROR', + ) + } + + const snippets = citationSnippets(blocks) + const seen = new Set<string>() + const sources: WebSearchSource[] = [] + for (const block of resultBlocks) { + for (const item of block.content ?? []) { + if (item.type !== 'web_search_result' || item.url.length === 0 || seen.has(item.url)) continue + seen.add(item.url) + const snippet = snippets.get(item.url) + sources.push({ + url: item.url, + ...item.title != null && item.title.length > 0 ? { title: item.title } : {}, + ...snippet != null && snippet.length > 0 ? { snippet } : {}, + ...item.page_age != null && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}, + }) + } + } + return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false } +} + +/** The DeepSeek-backed search provider. */ +export class DeepSeekSearchProvider implements WebSearchProvider { + readonly id = DEEPSEEK_PROVIDER_ID + + constructor(private readonly options: DeepSeekSearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { + let response: Response + try { + response = await fetch(`${this.options.baseURL}/messages`, { + method: 'POST', + headers: { + // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy + // may expect `Authorization: Bearer` — send both so either resolves. + 'x-api-key': this.options.apiKey, + 'authorization': `Bearer ${this.options.apiKey}`, + 'anthropic-version': this.options.apiVersion, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + model: this.options.model, + max_tokens: this.options.maxTokens, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }], + }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `DeepSeek API error (HTTP ${status})` + try { + const parsed = await response.json() as AnthropicError + const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + try { + const payload = await response.json() as AnthropicResponse + return mapAnthropicResponse(request.query, payload) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + } +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +/** True for DeepSeek request limits that can be sent to the Messages API. */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} diff --git a/packages/web/web-search-deepseek/src/types.ts b/packages/web/web-search-deepseek/src/types.ts new file mode 100644 index 0000000000..bd88ed9663 --- /dev/null +++ b/packages/web/web-search-deepseek/src/types.ts @@ -0,0 +1,58 @@ +/** + * Wire types for DeepSeek's Anthropic-compatible Messages API + * (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool + * enabled. Types only — no runtime code. + * + * DeepSeek returns structured content blocks: `web_search_tool_result` blocks + * carry the citeable `web_search_result` items (`url`/`title`/`page_age`), while + * the snippet/excerpt for a URL lives separately in a `text` block's + * `citations[]` (a `cited_text` keyed by `url`). The provider joins the two. + * + * The Anthropic wire shape is a provider-private detail; it does not make this + * provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-deepseek/types + */ + +/** A `web_search_result` item inside a `web_search_tool_result` block. */ +export interface WebSearchResultItem { + type: string + url: string + title?: string | null + /** Provider-supplied page age/recency string (mapped to `publishedAt`). */ + page_age?: string | null +} + +/** A `web_search_tool_result` content block: the citeable result surface. */ +export interface WebSearchToolResultBlock { + type: 'web_search_tool_result' + content?: WebSearchResultItem[] +} + +/** One citation location inside a `text` block (the snippet surface). */ +export interface CitationLocation { + type?: string + url?: string | null + cited_text?: string | null +} + +/** A `text` content block: the model's prose plus per-URL citations. */ +export interface TextBlock { + type: 'text' + text?: string | null + citations?: CitationLocation[] +} + +/** Any content block; only `web_search_tool_result` and `text` are consumed. */ +export type ContentBlock = WebSearchToolResultBlock | TextBlock | { type: string } + +/** DeepSeek's Anthropic Messages response envelope. */ +export interface AnthropicResponse { + content?: ContentBlock[] +} + +/** DeepSeek's error response envelope (best-effort; fields vary). */ +export interface AnthropicError { + error?: { message?: string } | string + message?: string +} diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts new file mode 100644 index 0000000000..f828fbd12e --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, +} from '@deepseek-ai/dsh-web-search-deepseek' + +/** + * Real-API smoke for the DeepSeek search provider. Self-skips without + * `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This + * is the only test that proves DeepSeek's Anthropic-compatible endpoint actually + * triggers native `web_search` and returns the structured result blocks the + * provider parses — a mock cannot confirm the wire shape is real. + */ +const apiKey = process.env.DEEPSEEK_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('DeepSeekSearchProvider real API', () => { + it('returns citeable sources for a live query via native web_search', async () => { + const provider = new DeepSeekSearchProvider({ + apiKey: apiKey!, + baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL, + model: process.env.DEEPSEEK_SEARCH_MODEL ?? DEEPSEEK_DEFAULT_MODEL, + apiVersion: DEEPSEEK_DEFAULT_API_VERSION, + maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS, + maxUses: DEEPSEEK_DEFAULT_MAX_USES, + }) + const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) + expect(result.providerId).toBe('deepseek') + expect(result.sources.length).toBeGreaterThan(0) + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 60_000) +}) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts new file mode 100644 index 0000000000..0faab1f35a --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import WebService from '@deepseek-ai/dsh-web' +import { + DeepSeekSearchProvider, + citationSnippets, + mapAnthropicResponse, + DEEPSEEK_PROVIDER_ID, +} from '@deepseek-ai/dsh-web-search-deepseek' +import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek' +import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts' + +const options = { + apiKey: 'ds-key', + baseURL: 'https://api.deepseek.test/anthropic/v1', + model: 'deepseek-chat', + apiVersion: '2023-06-01', + maxTokens: 4096, + maxUses: 5, +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +/** A response with one result block plus a text block carrying the snippet. */ +function searchResponse(): AnthropicResponse { + return { + content: [ + { type: 'text', text: 'Here is what I found.', citations: [{ type: 'web_search_result_location', url: 'https://a.test', cited_text: 'excerpt for A' }] }, + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'https://a.test', title: 'A', page_age: '2026-02-02' }, + { type: 'web_search_result', url: 'https://b.test', title: 'B' }, + ], + }, + ], + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('citationSnippets', () => { + it('maps url → cited_text from text blocks, first occurrence wins', () => { + const map = citationSnippets([ + { type: 'text', citations: [{ url: 'https://a.test', cited_text: 'first' }, { url: 'https://a.test', cited_text: 'second' }] }, + { type: 'text', citations: [{ url: 'https://b.test', cited_text: 'b text' }] }, + ]) + expect(map.get('https://a.test')).toBe('first') + expect(map.get('https://b.test')).toBe('b text') + }) + + it('ignores citations missing url or cited_text', () => { + const map = citationSnippets([ + { type: 'text', citations: [{ url: 'https://a.test' }, { cited_text: 'orphan' }, { url: '', cited_text: 'empty url' }] }, + ]) + expect(map.size).toBe(0) + }) +}) + +describe('mapAnthropicResponse', () => { + it('joins result items to citation snippets and maps page_age to publishedAt', () => { + const result = mapAnthropicResponse('q', searchResponse()) + expect(result).toEqual({ + providerId: DEEPSEEK_PROVIDER_ID, + query: 'q', + sources: [ + { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' }, + { url: 'https://b.test', title: 'B' }, + ], + truncated: false, + }) + }) + + it('dedupes repeated urls across result blocks (first wins)', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test', title: 'first' }]) + }) + + it('skips non-result items and items with an empty url', () => { + const result = mapAnthropicResponse('q', { + content: [{ + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result_error', url: 'https://err.test' }, + { type: 'web_search_result', url: '' }, + { type: 'web_search_result', url: 'https://ok.test' }, + ], + }], + }) + expect(result.sources).toEqual([{ url: 'https://ok.test' }]) + }) + + it('omits optional fields when absent or empty', () => { + const result = mapAnthropicResponse('q', { + content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('tolerates a text block with no citations', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'text', text: 'no citations here' }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test', title: 'A' }]) + }) + + it('tolerates a result block with no content array', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'web_search_tool_result' }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => { + expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] })) + .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => { + expect(() => mapAnthropicResponse('q', {})) + .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('DeepSeekSearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true }) + }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when request limits are not positive integers', () => { + expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) +}) + +describe('DeepSeekSearchProvider request mapping', () => { + it('posts an Anthropic Messages request enabling the web_search server tool', async () => { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + await new DeepSeekSearchProvider(options).search({ query: 'hello' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') + const headers = init.headers as Record<string, string> + expect(headers['x-api-key']).toBe('ds-key') + expect(headers['authorization']).toBe('Bearer ds-key') + expect(headers['anthropic-version']).toBe('2023-06-01') + expect(JSON.parse(init.body as string)).toEqual({ + model: 'deepseek-chat', + max_tokens: 4096, + messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }], + }) + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('DeepSeekSearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) + }) + + it('handles a string-form error body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 503)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 500)' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: {} }, { status: 200 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('strict mode flows through search(): a prose-only response throws WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: [{ type: 'text', text: 'no search happened' }] }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('web-search-deepseek plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) + await fiber.dispose() + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('rejects maxTokens: 0 at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxTokens: 0 })) + .rejects.toThrow(/maxTokens expected number >= 1/) + }) + + it('rejects maxUses: 0 at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 0 })) + .rejects.toThrow(/maxUses expected number >= 1/) + }) + + it('rejects a fractional maxUses at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 1.5 })) + .rejects.toThrow(/maxUses expected number multiple of 1/) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in deepseekPlugin).toBe(false) + }) + + it('survives the real Loader unwrapExports path keeping name/inject/Config', () => { + // A stray `export default apply` would make the cordis Loader's + // unwrapExports (`exports.default ?? exports`) collapse the module to the + // bare `apply` function, DROPPING `inject: ['web']` — the plugin would then + // read ctx.web without injecting it and throw "cannot get property … without + // inject" the moment it loads. A hand-built ctx.plugin(namespace) mount + // bypasses unwrapExports and cannot catch that, so drive the real path. + // Prove it bites: add `export default apply` to src/index.ts, watch this go + // red, revert. + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(deepseekPlugin) as Record<string, unknown> + expect(unwrapped).toBe(deepseekPlugin) + expect(unwrapped.name).toBe('web-search-deepseek') + expect(unwrapped.inject).toEqual(['web']) + expect(typeof unwrapped.apply).toBe('function') + }) + + it('boots over ctx.web through the unwrapped module without an inject error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0] + // A collapsed export shape (dropped inject) would throw "without inject" here. + const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) + await fiber.dispose() + }) + + it('falls back to the env key and defaults when config omits them', async () => { + const prev = process.env.DEEPSEEK_API_KEY + process.env.DEEPSEEK_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const fiber = await ctx.plugin(deepseekPlugin, {}) + await ctx.web.search({ query: 'q' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') + expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key') + expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' }) + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.DEEPSEEK_API_KEY + else process.env.DEEPSEEK_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.DEEPSEEK_API_KEY + delete process.env.DEEPSEEK_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await ctx.plugin(deepseekPlugin, {}) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + } finally { + if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md new file mode 100644 index 0000000000..0bc58d6559 --- /dev/null +++ b/packages/web/web-search-exa/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-web-search-exa + +An [Exa](https://exa.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Exa's `POST /search` endpoint with highlight contents and maps the flat `results[]` into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the `ctx.web` key and it does not register a model-facing tool (that is `@deepseek-ai/dsh-tool-web`). Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`) that registers its backend, not a default-export service. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | +| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. | +| `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. | +| `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. | + +```yaml +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + config: + apiKey: !!js process.env.EXA_API_KEY +``` + +## Mapping + +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json new file mode 100644 index 0000000000..a111daa287 --- /dev/null +++ b/packages/web/web-search-exa/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-web-search-exa", + "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts new file mode 100644 index 0000000000..39a20b16a4 --- /dev/null +++ b/packages/web/web-search-exa/src/index.ts @@ -0,0 +1,68 @@ +/** + * `@deepseek-ai/dsh-web-search-exa`: registers an Exa-backed `WebSearchProvider` + * with `ctx.web`. A function/namespace plugin (NOT a default-export service): + * a search provider does not own the `ctx.web` key — it registers INTO the + * seam's provider registry, exactly as `@deepseek-ai/dsh-llm-deepseek` + * registers an adapter into `ctx.llm`. The key is owned by `@deepseek-ai/dsh-web`. + * + * @module @deepseek-ai/dsh-web-search-exa + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { + ExaSearchProvider, + EXA_DEFAULT_BASE_URL, + EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + EXA_DEFAULT_SEARCH_TYPE, +} from './provider.ts' + +export { + EXA_DEFAULT_BASE_URL, + EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + EXA_DEFAULT_SEARCH_TYPE, + EXA_PROVIDER_ID, + ExaSearchProvider, + mapExaResponse, + mapExaResult, +} from './provider.ts' +export type { ExaSearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-exa' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ + apiKey?: string + /** Endpoint base; `/search` is appended. Defaults to the public API. */ + baseURL?: string + /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ + searchType?: 'auto' | 'keyword' | 'neural' + /** Default result count when a request carries no `maxResults`. Omitted = none. */ + numResults?: number + /** Highlight sentences requested per result. Defaults to 1. */ + highlightsPerResult?: number +} + +export const Config: z<Config> = z.object({ + apiKey: z.string(), + baseURL: z.string(), + searchType: z.union(['auto', 'keyword', 'neural'] as const), + numResults: z.number().step(1).min(1), + highlightsPerResult: z.number().step(1).min(1), +}) + +/** Register the Exa search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + ctx.web.registerSearchProvider(new ExaSearchProvider({ + apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '', + baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, + searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, + highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + ...config.numResults !== undefined ? { numResults: config.numResults } : {}, + })) +} diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts new file mode 100644 index 0000000000..f187f90344 --- /dev/null +++ b/packages/web/web-search-exa/src/provider.ts @@ -0,0 +1,161 @@ +/** + * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API + * (`POST /search` with highlight contents). Maps Exa's flat `results[]` into the + * seam's normalized `WebSearchResult`. Exa returns no provider-generated answer, + * so `content` is omitted; each result maps to a `WebSearchSource` with `url`, + * `title`, the first highlight as `snippet`, and `publishedDate` as + * `publishedAt`. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. + * + * @module @deepseek-ai/dsh-web-search-exa/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { ExaError, ExaResult, ExaSearchResponse } from './types.ts' + +/** Stable id this provider registers under. */ +export const EXA_PROVIDER_ID = 'exa' + +/** Default Exa search endpoint; `/search` is the operation. */ +export const EXA_DEFAULT_BASE_URL = 'https://api.exa.ai' + +/** Default retrieval mode: let Exa pick between keyword and neural search. */ +export const EXA_DEFAULT_SEARCH_TYPE = 'auto' + +/** Default number of highlight sentences requested per result. */ +export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1 + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface ExaSearchProviderOptions { + /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/search` is appended. */ + baseURL: string + /** Retrieval mode sent as Exa's `type`. */ + searchType: 'auto' | 'keyword' | 'neural' + /** Default result count when a request carries no `maxResults`. */ + numResults?: number + /** Highlight sentences requested per result (Exa's `highlightsPerUrl`). */ + highlightsPerResult: number +} + +/** + * Map one Exa result to a normalized source, or `undefined` when it carries no + * portable snippet (an entry with no highlight is dropped — the seam has no + * other field to derive a snippet from, and inventing one would lie). + */ +export function mapExaResult(result: ExaResult): WebSearchSource | undefined { + const snippet = result.highlights?.find(highlight => highlight.trim().length > 0) + if (snippet === undefined) return undefined + return { + url: result.url, + ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, + snippet, + ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}, + } +} + +/** Map an Exa response envelope to a normalized search result. */ +export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { + const sources = (response.results ?? []) + .map(mapExaResult) + .filter((source): source is WebSearchSource => source !== undefined) + // Exa returns no generated answer, so `content` is omitted. The seam owns the + // final `maxResults` truncation, so this provider reports `truncated: false`. + return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false } +} + +/** The Exa-backed search provider. */ +export class ExaSearchProvider implements WebSearchProvider { + readonly id = EXA_PROVIDER_ID + + constructor(private readonly options: ExaSearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' } + if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { + // A per-request bound wins over the configured default; either may be absent. + const numResults = request.maxResults ?? this.options.numResults + let response: Response + try { + response = await fetch(`${this.options.baseURL}/search`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + query: request.query, + type: this.options.searchType, + contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, + ...numResults !== undefined ? { numResults } : {}, + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Exa search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `Exa API error (HTTP ${status})` + try { + const parsed = await response.json() as ExaError + const detail = parsed.error ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + try { + const payload = await response.json() as ExaSearchResponse + return mapExaResponse(request.query, payload) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + } +} + +/** True when `baseURL` parses as an absolute URL (a cheap local config check). */ +function isValidBaseUrl(baseURL: string): boolean { + return URL.canParse(baseURL) +} + +/** True for a request limit that can be sent to Exa (a positive whole number). */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} diff --git a/packages/web/web-search-exa/src/types.ts b/packages/web/web-search-exa/src/types.ts new file mode 100644 index 0000000000..fae42d07cd --- /dev/null +++ b/packages/web/web-search-exa/src/types.ts @@ -0,0 +1,38 @@ +/** + * Wire types for the Exa search API (`POST https://api.exa.ai/search`). Types + * only — no runtime code. Exa returns a flat `results[]`; each entry carries a + * URL, optional title, optional `publishedDate`, and (when highlights are + * requested) a `highlights[]` array of salient sentences. + * + * @module @deepseek-ai/dsh-web-search-exa/types + */ + +/** Request body sent to Exa's search endpoint. */ +export interface ExaSearchRequest { + query: string + /** Retrieval mode: keyword, neural (embeddings), or auto (Exa decides). */ + type: 'auto' | 'keyword' | 'neural' + /** Exa's result-count control; the seam still enforces the bound on return. */ + numResults?: number + /** Ask Exa to return highlight sentences per result. */ + contents: { highlights: { highlightsPerUrl: number } } +} + +/** One entry of Exa's flat `results[]`. */ +export interface ExaResult { + url: string + title?: string | null + publishedDate?: string | null + highlights?: string[] +} + +/** Exa's search response envelope. */ +export interface ExaSearchResponse { + results?: ExaResult[] +} + +/** Exa's error response envelope (best-effort; fields vary by failure). */ +export interface ExaError { + error?: string + message?: string +} diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts new file mode 100644 index 0000000000..c9e9233bc0 --- /dev/null +++ b/packages/web/web-search-exa/tests/exa.e2e.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE } from '@deepseek-ai/dsh-web-search-exa' + +/** + * Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY` + * (CI has no secrets), per the with-key e2e policy in docs/testing.md. + */ +const apiKey = process.env.EXA_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('ExaSearchProvider real API', () => { + it('returns sources for a live query', async () => { + const provider = new ExaSearchProvider({ + apiKey: apiKey!, + baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL, + searchType: EXA_DEFAULT_SEARCH_TYPE, + highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + }) + const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 }) + expect(result.providerId).toBe('exa') + expect(result.sources.length).toBeGreaterThan(0) + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 30_000) +}) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts new file mode 100644 index 0000000000..204b18f702 --- /dev/null +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { ExaSearchProvider, mapExaResponse, mapExaResult, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' +import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' + +const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 } + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Exa result mapping', () => { + it('maps a full result entry', () => { + expect(mapExaResult({ + url: 'https://a.test', + title: 'A', + publishedDate: '2026-01-01', + highlights: ['salient sentence', 'second'], + })).toEqual({ url: 'https://a.test', title: 'A', snippet: 'salient sentence', publishedAt: '2026-01-01' }) + }) + + it('drops a result with no usable highlight', () => { + expect(mapExaResult({ url: 'https://a.test', highlights: [] })).toBeUndefined() + expect(mapExaResult({ url: 'https://a.test' })).toBeUndefined() + expect(mapExaResult({ url: 'https://a.test', highlights: [' '] })).toBeUndefined() + }) + + it('omits null/empty optional fields rather than emitting them', () => { + expect(mapExaResult({ url: 'https://a.test', title: null, publishedDate: null, highlights: ['hi'] })) + .toEqual({ url: 'https://a.test', snippet: 'hi' }) + expect(mapExaResult({ url: 'https://a.test', title: '', publishedDate: '', highlights: ['hi'] })) + .toEqual({ url: 'https://a.test', snippet: 'hi' }) + }) + + it('maps a response to a result with no content and filtered sources', () => { + const result = mapExaResponse('q', { + results: [ + { url: 'https://a.test', highlights: ['one'] }, + { url: 'https://b.test' }, + { url: 'https://c.test', title: 'C', highlights: ['three'] }, + ], + }) + expect(result).toEqual({ + providerId: EXA_PROVIDER_ID, + query: 'q', + sources: [ + { url: 'https://a.test', snippet: 'one' }, + { url: 'https://c.test', title: 'C', snippet: 'three' }, + ], + truncated: false, + }) + expect(result.content).toBeUndefined() + }) + + it('tolerates a missing results array', () => { + expect(mapExaResponse('q', {}).sources).toEqual([]) + }) + +}) + +describe('ExaSearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new ExaSearchProvider({ ...options, apiKey: '' }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new ExaSearchProvider(options).status()).toEqual({ available: true }) + }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when highlightsPerResult is not a positive integer', () => { + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when numResults is set but not a positive integer', () => { + expect(new ExaSearchProvider({ ...options, numResults: -1 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) +}) + +describe('ExaSearchProvider request mapping', () => { + it('sends query, type, highlights, numResults and bearer auth', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] })) + vi.stubGlobal('fetch', fetchMock) + + const provider = new ExaSearchProvider({ ...options, searchType: 'neural', highlightsPerResult: 3 }) + await provider.search({ query: 'hello', maxResults: 5 }) + + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.exa.test/search') + expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key') + expect(JSON.parse(init.body as string)).toEqual({ + query: 'hello', + type: 'neural', + contents: { highlights: { highlightsPerUrl: 3 } }, + numResults: 5, + }) + }) + + it('falls back to the configured numResults when a request omits maxResults', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 7 }) + }) + + it('lets a request maxResults win over the configured numResults', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q', maxResults: 2 }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 2 }) + }) + + it('omits numResults when neither maxResults nor a configured default is set', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider(options).search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).not.toHaveProperty('numResults') + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('ExaSearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad key' }, { status: 401 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'bad key' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'Exa API error (HTTP 502)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Exa API error (HTTP 500)' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) +}) + +describe('web-search-exa plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] }))) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID }) + await fiber.dispose() + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in exaPlugin).toBe(false) + }) + + it('threads searchType, highlightsPerResult and numResults config into the request', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2, numResults: 9 }) + await ctx.web.search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } }, numResults: 9 }) + await fiber.dispose() + }) + + it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => { + const prev = process.env.EXA_API_KEY + process.env.EXA_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, {}) + await ctx.web.search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://api.exa.ai/search') + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.EXA_API_KEY + else process.env.EXA_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.EXA_API_KEY + delete process.env.EXA_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + await ctx.plugin(exaPlugin, {}) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + } finally { + if (prev !== undefined) process.env.EXA_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-search-exa/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md new file mode 100644 index 0000000000..f944413c96 --- /dev/null +++ b/packages/web/web-search-perplexity/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-web-search-perplexity + +A [Perplexity](https://perplexity.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Perplexity's OpenAI-compatible `POST /chat/completions` endpoint and maps the generated answer plus citations into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The OpenAI-compatible wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | +| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `model` | `sonar` | Search model name. | +| `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. | +| `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. | + +```yaml +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + config: + apiKey: !!js process.env.PERPLEXITY_API_KEY +``` + +## Mapping + +`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json new file mode 100644 index 0000000000..fde44ddd16 --- /dev/null +++ b/packages/web/web-search-perplexity/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-web-search-perplexity", + "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts new file mode 100644 index 0000000000..3d375eaabb --- /dev/null +++ b/packages/web/web-search-perplexity/src/index.ts @@ -0,0 +1,62 @@ +/** + * `@deepseek-ai/dsh-web-search-perplexity`: registers a Perplexity-backed + * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's provider registry, like + * `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' + +export { + PERPLEXITY_DEFAULT_BASE_URL, + PERPLEXITY_DEFAULT_MAX_TOKENS, + PERPLEXITY_DEFAULT_MODEL, + PERPLEXITY_PROVIDER_ID, + PerplexitySearchProvider, + mapPerplexityResponse, + mapPerplexityResult, +} from './provider.ts' +export type { PerplexityRecency, PerplexitySearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-perplexity' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ + apiKey?: string + /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ + baseURL?: string + /** Search model name. Defaults to `sonar`. */ + model?: string + /** Upper bound on generated answer tokens. Defaults to 1024. */ + maxTokens?: number + /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ + searchRecency?: 'day' | 'week' | 'month' | 'year' +} + +export const Config: z<Config> = z.object({ + apiKey: z.string(), + baseURL: z.string(), + model: z.string(), + maxTokens: z.number().step(1).min(1), + searchRecency: z.union(['day', 'week', 'month', 'year'] as const), +}) + +/** Register the Perplexity search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + ctx.web.registerSearchProvider(new PerplexitySearchProvider({ + apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '', + baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, + model: config.model ?? PERPLEXITY_DEFAULT_MODEL, + maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, + ...config.searchRecency !== undefined ? { searchRecency: config.searchRecency } : {}, + })) +} diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts new file mode 100644 index 0000000000..ed72ea82c3 --- /dev/null +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -0,0 +1,160 @@ +/** + * `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity + * search API (an OpenAI-compatible `POST /chat/completions`). Maps the generated + * answer (`choices[0].message.content`) into `content`, and prefers the + * structured `search_results[]` for `sources[]`, falling back to the URL-only + * `citations[]` when `search_results` is absent. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape + * is a provider-private detail and does NOT make this provider depend on + * `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { PerplexityError, PerplexityResponse, PerplexitySearchResult } from './types.ts' + +/** Stable id this provider registers under. */ +export const PERPLEXITY_PROVIDER_ID = 'perplexity' + +/** Default Perplexity endpoint; `/chat/completions` is the operation. */ +export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai' + +/** Default search model. */ +export const PERPLEXITY_DEFAULT_MODEL = 'sonar' + +/** Default upper bound on generated answer tokens. */ +export const PERPLEXITY_DEFAULT_MAX_TOKENS = 1024 + +/** Recency filter values Perplexity accepts for `search_recency_filter`. */ +export type PerplexityRecency = 'day' | 'week' | 'month' | 'year' + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface PerplexitySearchProviderOptions { + /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/chat/completions` is appended. */ + baseURL: string + /** Search model name. */ + model: string + /** Upper bound on generated answer tokens (`max_tokens`). */ + maxTokens: number + /** Optional recency window sent as `search_recency_filter`; omitted = no filter. */ + searchRecency?: PerplexityRecency +} + +/** Map one structured Perplexity search result to a normalized source. */ +export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource { + return { + url: result.url, + ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, + ...result.snippet != null && result.snippet.length > 0 ? { snippet: result.snippet } : {}, + ...result.date != null && result.date.length > 0 ? { publishedAt: result.date } : {}, + } +} + +/** + * Map a Perplexity response envelope to a normalized search result. Prefers + * structured `search_results[]`; falls back to URL-only `citations[]` (those + * sources carry just a `url`) only when `search_results` is absent. + */ +export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult { + const content = response.choices?.[0]?.message?.content + const sources: WebSearchSource[] = response.search_results !== undefined + ? response.search_results.map(mapPerplexityResult) + : (response.citations ?? []).map(url => ({ url })) + return { + providerId: PERPLEXITY_PROVIDER_ID, + query, + ...content != null && content.length > 0 ? { content } : {}, + sources, + truncated: false, + } +} + +/** The Perplexity-backed search provider. */ +export class PerplexitySearchProvider implements WebSearchProvider { + readonly id = PERPLEXITY_PROVIDER_ID + + constructor(private readonly options: PerplexitySearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { + let response: Response + try { + response = await fetch(`${this.options.baseURL}/chat/completions`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + model: this.options.model, + max_tokens: this.options.maxTokens, + messages: [{ role: 'user', content: request.query }], + ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Perplexity search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `Perplexity API error (HTTP ${status})` + try { + const parsed = await response.json() as PerplexityError + const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + try { + const payload = await response.json() as PerplexityResponse + return mapPerplexityResponse(request.query, payload) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + } +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +/** True for a request limit that can be sent to Perplexity (a positive whole number). */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} diff --git a/packages/web/web-search-perplexity/src/types.ts b/packages/web/web-search-perplexity/src/types.ts new file mode 100644 index 0000000000..7b1f2e32b0 --- /dev/null +++ b/packages/web/web-search-perplexity/src/types.ts @@ -0,0 +1,41 @@ +/** + * Wire types for the Perplexity search API + * (`POST https://api.perplexity.ai/chat/completions`, an OpenAI-compatible chat + * shape). Types only — no runtime code. Perplexity returns a generated answer in + * `choices[0].message.content` plus citation surfaces: a structured + * `search_results[]` (preferred) and a URL-only `citations[]` fallback. + * + * The OpenAI-compatible wire shape is a provider-private detail; it does not make + * this provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity/types + */ + +/** Request body sent to Perplexity's chat-completions endpoint. */ +export interface PerplexityRequest { + model: string + messages: { role: 'user'; content: string }[] +} + +/** One structured search result (the preferred citation surface). */ +export interface PerplexitySearchResult { + url: string + title?: string | null + snippet?: string | null + date?: string | null +} + +/** Perplexity's response envelope. */ +export interface PerplexityResponse { + choices?: { message?: { content?: string | null } }[] + /** Structured citation surface (preferred). */ + search_results?: PerplexitySearchResult[] + /** URL-only citation fallback. */ + citations?: string[] +} + +/** Perplexity's error response envelope (best-effort; fields vary). */ +export interface PerplexityError { + error?: { message?: string } | string + message?: string +} diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts new file mode 100644 index 0000000000..2fc89db7d4 --- /dev/null +++ b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity' + +/** + * Real-API smoke for the Perplexity search provider. Self-skips without + * `$PERPLEXITY_API_KEY`, per the with-key e2e policy in docs/testing.md. + */ +const apiKey = process.env.PERPLEXITY_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('PerplexitySearchProvider real API', () => { + it('returns a generated answer and sources for a live query', async () => { + const provider = new PerplexitySearchProvider({ + apiKey: apiKey!, + baseURL: process.env.PERPLEXITY_BASE_URL ?? PERPLEXITY_DEFAULT_BASE_URL, + model: process.env.PERPLEXITY_MODEL ?? PERPLEXITY_DEFAULT_MODEL, + maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, + }) + const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) + expect(result.providerId).toBe('perplexity') + expect(result.content ?? '').not.toBe('') + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 30_000) +}) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts new file mode 100644 index 0000000000..df8a98f003 --- /dev/null +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { + PerplexitySearchProvider, + mapPerplexityResponse, + PERPLEXITY_PROVIDER_ID, +} from '@deepseek-ai/dsh-web-search-perplexity' +import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity' + +const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar', maxTokens: 1024 } + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Perplexity response mapping', () => { + it('maps the answer and prefers structured search_results', () => { + const result = mapPerplexityResponse('q', { + choices: [{ message: { content: 'the answer' } }], + search_results: [ + { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, + { url: 'https://b.test' }, + ], + citations: ['https://ignored.test'], + }) + expect(result).toEqual({ + providerId: PERPLEXITY_PROVIDER_ID, + query: 'q', + content: 'the answer', + sources: [ + { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' }, + { url: 'https://b.test' }, + ], + truncated: false, + }) + }) + + it('falls back to URL-only citations when search_results is absent', () => { + const result = mapPerplexityResponse('q', { + choices: [{ message: { content: 'answer' } }], + citations: ['https://a.test', 'https://b.test'], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }, { url: 'https://b.test' }]) + }) + + it('omits content when the answer is empty or missing', () => { + expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined() + expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined() + expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined() + }) + + it('omits null/empty optional source fields', () => { + const result = mapPerplexityResponse('q', { + search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('yields no sources when neither search_results nor citations are present', () => { + expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) + }) +}) + +describe('PerplexitySearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true }) + }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when maxTokens is not a positive integer', () => { + expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) +}) + +describe('PerplexitySearchProvider request mapping', () => { + it('sends a chat-completions request with the query, model and max_tokens', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + await new PerplexitySearchProvider(options).search({ query: 'hello' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.perplexity.test/chat/completions') + expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer pplx-key') + expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] }) + }) + + it('sends search_recency_filter when configured, and omits it otherwise', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + await new PerplexitySearchProvider({ ...options, searchRecency: 'week' }).search({ query: 'q' }) + expect(JSON.parse((fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].body as string)).toMatchObject({ search_recency_filter: 'week' }) + + await new PerplexitySearchProvider(options).search({ query: 'q' }) + expect(JSON.parse((fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body as string)).not.toHaveProperty('search_recency_filter') + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('PerplexitySearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) + }) + + it('handles a string-form error body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) + }) + + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ search_results: null }, { status: 200 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 503)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 500)' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('web-search-perplexity plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] }))) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID }) + await fiber.dispose() + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in perplexityPlugin).toBe(false) + }) + + it('threads maxTokens and searchRecency config into the request', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key', maxTokens: 256, searchRecency: 'month' }) + await ctx.web.search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ max_tokens: 256, search_recency_filter: 'month' }) + await fiber.dispose() + }) + + it('falls back to env key and defaults for base URL and model when config omits them', async () => { + const prev = process.env.PERPLEXITY_API_KEY + process.env.PERPLEXITY_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, {}) + await ctx.web.search({ query: 'q' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.perplexity.ai/chat/completions') + expect(JSON.parse(init.body as string)).toMatchObject({ model: 'sonar' }) + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.PERPLEXITY_API_KEY + else process.env.PERPLEXITY_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.PERPLEXITY_API_KEY + delete process.env.PERPLEXITY_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + await ctx.plugin(perplexityPlugin, {}) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + } finally { + if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web/README.md b/packages/web/web/README.md new file mode 100644 index 0000000000..fe5f1e650e --- /dev/null +++ b/packages/web/web/README.md @@ -0,0 +1,44 @@ +# @deepseek-ai/dsh-web + +The **web access seam**: an abstract `WebService` (`ctx.web`) defining WHAT web access the harness has — search the web, fetch a URL — over multiple providers, without binding the model contract to one vendor's API shape. + +This package is the interface third of the web capability. Unlike bash/fs it spans two capabilities (search and fetch) on one seam, with potentially multiple providers each: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-web` (this) | the interface: the service, provider registries, selection policy, request/result vocabulary, the `WebError` taxonomy | +| `@deepseek-ai/dsh-web-search-exa` | a search implementation: Exa | +| `@deepseek-ai/dsh-web-search-perplexity` | a search implementation: Perplexity | +| `@deepseek-ai/dsh-web-fetch-local` | a fetch implementation: anonymous public HTTP(S) | +| `@deepseek-ai/dsh-tool-web` | the model-facing `web_search` / `web_fetch` tool schemas over `ctx.web` | + +Search and fetch share no request schema and no business logic, but they are deliberately one seam: `ctx.web` is a single web-access middle layer with one provider-selection policy owner, one abort/error vocabulary, and one product-facing "how this harness reaches the web" config surface. The cost is the parallel `Search`/`Fetch` method pairs; that parallelism is intentional, not a missed extraction. + +## Service API (`ctx.web`) + +| Member | Semantics | +|---|---| +| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. | +| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | +| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | + +Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. + +## Selection + +Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered. `search()`/`fetch()` resolve the provider at execution time: + +| Situation | Execution | +|---|---| +| configured id registered and `status().available` | runs that provider | +| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` | +| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| no id, exactly one registered usable provider | runs it | +| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | +| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | + +The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner. + +## Vocabulary + +`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy. diff --git a/packages/support/ui-stdio/package.json b/packages/web/web/package.json similarity index 67% rename from packages/support/ui-stdio/package.json rename to packages/web/web/package.json index 5d65356e79..8c68c58203 100644 --- a/packages/support/ui-stdio/package.json +++ b/packages/web/web/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-ui-stdio", - "description": "Minimal stdio (readline) UI plugin: renders agent/* events to stdout and feeds stdin lines to the agent", + "name": "@deepseek-ai/dsh-web", + "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", "version": "0.0.1", "private": true, "type": "module", @@ -22,18 +22,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts new file mode 100644 index 0000000000..5da736a85a --- /dev/null +++ b/packages/web/web/src/index.ts @@ -0,0 +1,215 @@ +/** + * The web access seam (`ctx.web`): a provider registry plus a provider-selecting + * execution surface for two capabilities — search and fetch. Provider packages + * register concrete backends with `registerSearchProvider` / + * `registerFetchProvider`; the model-facing consumer + * (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and + * routes on the structured {@link WebError} codes selection throws. + * + * The registry half stays close to `LlmService`: a `Map<id, provider>` per + * capability kind, register methods that return disposers, duplicate ids that + * throw, and execution-time resolution that throws when the selected provider is + * absent or unusable — with selection rules that never depend on registration + * order. + * + * @module @deepseek-ai/dsh-web + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { + WebExecContext, + WebFetchProvider, + WebFetchRequest, + WebFetchResult, + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, +} from './types.ts' +import { WebError } from './types.ts' + +export { + WebError, +} from './types.ts' +export type { + WebExecContext, + WebFetchBody, + WebFetchProvider, + WebFetchRequest, + WebFetchResult, + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from './types.ts' + +declare module 'cordis' { + interface Context { + web: WebService + } +} + +/** Selection inputs for execution-time provider resolution. */ +interface Selection<P> { + /** The configured provider id for this capability, if any. */ + readonly configuredId?: string + /** Providers registered for this capability kind. */ + readonly providers: ReadonlyMap<string, P> +} + +/** + * Config for the web seam. `searchProvider` / `fetchProvider` pin which provider + * wins for each capability; both are optional (a single registered usable + * provider auto-selects). Operational overrides such as environment variables + * must feed these same fields rather than introduce a hidden priority chain. + */ +export interface WebServiceConfig { + /** Explicit search provider id. Omitted = auto-select when exactly one usable. */ + readonly searchProvider?: string + /** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */ + readonly fetchProvider?: string +} + +/** + * The web access service. Registered as `ctx.web` (one instance per context). + * + * Selection semantics (resolved at execution time, never order-dependent): + * - A configured id that is registered and `status().available` → that provider. + * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. + * - A configured id registered but unavailable → + * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. + * - No id configured, exactly one registered usable provider → that provider. + * - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. + * - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. + */ +export class WebService extends Service { + /** + * Provider selection config. Operational env overrides feed the SAME fields: + * `$DSH_WEB_SEARCH_PROVIDER` / `$DSH_WEB_FETCH_PROVIDER` are equivalent to + * `searchProvider` / `fetchProvider` and are NOT a hidden priority chain. + */ + static Config: z<WebServiceConfig> = z.object({ + searchProvider: z.string(), + fetchProvider: z.string(), + }) + + private searchProviders = new Map<string, WebSearchProvider>() + private fetchProviders = new Map<string, WebFetchProvider>() + private readonly searchProviderId: string | undefined + private readonly fetchProviderId: string | undefined + + constructor(ctx: Context, config: WebServiceConfig = {}) { + super(ctx, 'web') + this.searchProviderId = config.searchProvider ?? process.env.DSH_WEB_SEARCH_PROVIDER + this.fetchProviderId = config.fetchProvider ?? process.env.DSH_WEB_FETCH_PROVIDER + } + + /** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ + registerSearchProvider(provider: WebSearchProvider): () => void { + return this.registerProvider(this.searchProviders, provider) + } + + /** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ + registerFetchProvider(provider: WebFetchProvider): () => void { + return this.registerProvider(this.fetchProviders, provider) + } + + private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void { + if (store.has(provider.id)) { + throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER') + } + const dispose = this.ctx.effect(function* () { + store.set(provider.id, provider) + yield () => store.delete(provider.id) + }, 'web.registerProvider()') + // ctx.effect's disposer returns Promise<void>; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. + */ + async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> { + const provider = resolveProvider({ + providers: this.searchProviders, + ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, + }) + const result = await provider.search(request, exec) + return capSources(result, request.maxResults) + } + + /** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. + */ + async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> { + const provider = resolveProvider({ + providers: this.fetchProviders, + ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, + }) + return provider.fetch(request, exec) + } +} + +interface ResolvableProvider { + readonly id: string + status(): WebProviderStatus +} + +/** Resolve the selected provider or throw the matching {@link WebError}. */ +function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P { + const { configuredId, providers } = selection + if (configuredId !== undefined) { + const provider = providers.get(configuredId) + if (!provider) { + throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') + } + if (!provider.status().available) { + throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE') + } + return provider + } + const usable = [...providers.values()].filter(provider => provider.status().available) + const [single] = usable + if (single === undefined) { + throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') + } + if (usable.length > 1) { + const ids = usable.map(provider => provider.id).join(', ') + throw new WebError(`multiple usable web providers are registered (${ids}); configure one explicitly`, 'WEB_PROVIDER_AMBIGUOUS') + } + return single +} + +/** Enforce `maxResults` on a search result: truncate `sources[]` and flag it. */ +function capSources(result: WebSearchResult, maxResults: number | undefined): WebSearchResult { + if (maxResults === undefined || result.sources.length <= maxResults) return result + return { ...result, sources: result.sources.slice(0, maxResults), truncated: true } +} + +export default WebService diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts new file mode 100644 index 0000000000..f4cda691b8 --- /dev/null +++ b/packages/web/web/src/types.ts @@ -0,0 +1,200 @@ +/** + * Vocabulary for the web capability seam (`ctx.web`): the search/fetch + * request/result shapes providers produce and consumers format, the provider + * status discriminant selection reads, the execution-control context, and the + * typed error taxonomy. + * + * These types are shared by every provider backend + * (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, + * `@deepseek-ai/dsh-web-fetch-local`, and future backends) and by the + * model-facing consumer (`@deepseek-ai/dsh-tool-web`). Search and fetch share no + * request schema and no business logic, but they are deliberately one seam: + * `ctx.web` is a single web-access middle layer with one provider-selection + * policy, one abort/error vocabulary, and one product-facing configuration + * point. The cost is the parallel `Search`/`Fetch` shapes below; that + * parallelism is intentional. + * + * @module @deepseek-ai/dsh-web/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Execution control threaded from the tool layer through the seam into a + * provider's network requests, stream readers, and expensive decoding. It is + * NOT business input: the first version carries only `signal` so `tool-web` can + * propagate turn cancellation, tool timeout, and agent disposal. It deliberately + * does NOT carry `ToolExecution`, which would make `dsh-web` depend on + * `dsh-tools`. + */ +export interface WebExecContext { + /** Abort signal a provider must honor for its network/decoding work. */ + readonly signal?: AbortSignal +} + +/** + * What one search-capable backend can return. The model-facing argument is just + * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged + * and enforced on the way back by the seam (see {@link WebSearchResult}). + */ +export interface WebSearchRequest { + readonly query: string + /** + * Upper bound on returned sources; the seam truncates to it. Omitted = no + * bound. `dsh-tool-web` always sets it. A provider whose API supports a + * result-count control (Exa's `numResults`) should apply it at the request + * layer as a cost/latency optimization; the seam enforces the bound + * regardless. + */ + readonly maxResults?: number +} + +/** + * Normalized search outcome. `content` is optional provider-generated answer + * text or summary (Exa returns none; Perplexity returns a generated answer). + * `sources[]` is the portable citation surface. `truncated` is set by the seam + * when it cut `sources[]` down to `maxResults`. + */ +export interface WebSearchResult { + /** Id of the provider that produced this result. */ + readonly providerId: string + /** Echo of the query the provider answered. */ + readonly query: string + /** Optional provider-generated answer text, search context, or summary. */ + readonly content?: string + /** Citeable sources, already truncated to the request's `maxResults`. */ + readonly sources: readonly WebSearchSource[] + /** True when the seam dropped sources to honor `maxResults`. */ + readonly truncated: boolean +} + +/** + * One citeable source. A source always has a URL; `title`, `snippet`, and + * `publishedAt` are optional because not every provider returns them — forcing + * adapters to invent them would make the seam lie (Perplexity citations may be + * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + */ +export interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ + readonly publishedAt?: string +} + +/** + * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an + * optional positive hint the provider caps. The request deliberately omits + * `format`, `prompt`, and extraction controls — those are presentation or + * higher-level LLM concerns, not safe-retrieval inputs. + */ +export interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} + +/** + * Normalized fetch outcome. A successful network fetch of a non-2xx response is + * a result, not an error: the status code is part of the fetched resource + * state. {@link WebError} is reserved for failures to safely retrieve or + * represent the resource. + */ +export interface WebFetchResult { + /** Id of the provider that produced this result. */ + readonly providerId: string + /** The final URL after allowed redirects (the request URL is in the request). */ + readonly url: string + /** HTTP status code of the fetched response. */ + readonly statusCode: number + /** Decoded body, classified by content kind. */ + readonly body: WebFetchBody + /** True when the provider capped the decoded body. */ + readonly truncated: boolean +} + +/** + * The decoded body of a fetched resource. A CLOSED discriminated union owned by + * `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a + * new kind is a coordinated change across known packages, not a plugin + * extension. Consumers `switch` on `kind` ending in `default: assertNever(...)` + * so adding a kind breaks compilation at every consumer until handled. Each arm + * stays its own object literal even where fields coincide today, leaving room + * for arm-specific fields later (a `pdf` body's `pageCount`). + */ +export type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } + +/** + * Whether one concrete provider implementation is usable, by cheap local checks + * only (credential presence, parseable endpoint config). A provider `status()` + * must NOT make network calls. It is an input to execution-time selection, not + * a health system: `WebService.search()`/`fetch()` read it to pick a usable + * provider, and selection failure surfaces as the structured {@link WebError} + * codes callers route on. + */ +export type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } + +/** + * A search-capable backend. Registered with `ctx.web.registerSearchProvider`. + * `id` is a stable string, unique within the search capability kind. + */ +export interface WebSearchProvider { + readonly id: string + /** Cheap local usability check; must not make network calls. */ + status(): WebProviderStatus + /** Run one search; honor `exec.signal` for cancellation. */ + search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> +} + +/** + * A fetch-capable backend. Registered with `ctx.web.registerFetchProvider`. + * `id` is a stable string, unique within the fetch capability kind. + */ +export interface WebFetchProvider { + readonly id: string + /** Cheap local usability check; must not make network calls. */ + status(): WebProviderStatus + /** Retrieve one URL; honor `exec.signal` for cancellation. */ + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> +} + +/** + * Typed web error. Extends {@link HarnessError} so it carries a stable, + * machine-routable `code` (a `string`, like every other seam's error) and + * chains `cause`. `ToolRegistry.execute()` converts a thrown `WebError` into an + * error tool result whose structured metadata exposes the code, so callers + * (hooks, tests, UI) route on it. + * + * The `code` is an open `string`, NOT a closed union: a provider may raise its + * own codes without editing this package, and a consumer must tolerate an + * unknown code (a future provider will introduce ones this file never named). + * The codes split by who owns them — seam-neutral codes any provider may see, + * versus codes specific to a single implementation: + * + * Seam-neutral (raised by `WebService` selection and the shared contract): + * - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable. + * - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered. + * - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its + * `status()` reports unavailable. + * - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers + * exist (selection refuses to pick by registration order). + * - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is + * already registered for that capability kind. + * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. + * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced + * through the seam, including network/transport failure (DNS, connection + * refused, TLS). + * + * Fetch-transport codes (owned by the `dsh-web-fetch-local` implementation; a + * different fetch backend need not raise these and may raise its own): + * - `WEB_INVALID_URL`: the fetch URL is malformed or not http(s). + * - `WEB_BLOCKED_URL`: the fetch URL is rejected by policy (credentials in URL). + * - `WEB_REDIRECT_BLOCKED`: a cross-origin redirect was refused. + * - `WEB_FETCH_TOO_LARGE`: the response exceeded the byte/character cap. + * - `WEB_FETCH_TIMEOUT`: the fetch exceeded its timeout. + * - `WEB_UNSUPPORTED_CONTENT_TYPE`: the response content type cannot be decoded. + */ +export class WebError extends HarnessError {} diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts new file mode 100644 index 0000000000..8189e342da --- /dev/null +++ b/packages/web/web/tests/web.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import WebService, { + WebError, + type WebFetchProvider, + type WebFetchResult, + type WebProviderStatus, + type WebSearchProvider, + type WebSearchRequest, + type WebSearchResult, +} from '@deepseek-ai/dsh-web' + +/** A scripted search provider for contract tests. */ +function makeSearchProvider( + id: string, + status: WebProviderStatus, + search: (request: WebSearchRequest) => Promise<WebSearchResult>, +): WebSearchProvider { + return { id, status: () => status, search: request => search(request) } +} + +function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider { + return { id, status: () => status, fetch: () => Promise.resolve(result) } +} + +const available: WebProviderStatus = { available: true } +const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' } + +function searchResult(providerId: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult { + return { providerId, query: 'q', sources: [], truncated: false, ...overrides } +} + +function fetchResult(providerId: string): WebFetchResult { + return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false } +} + +/** Mount a WebService on a fresh root context with the given config. */ +async function mountWeb(config: ConstructorParameters<typeof WebService>[1] = {}): Promise<{ ctx: Context; web: WebService }> { + const ctx = new Context() + await ctx.plugin(WebService, config) + return { ctx, web: ctx.web } +} + +describe('WebService registration', () => { + it('registers a search provider and unregisters it via the returned disposer', async () => { + const { web } = await mountWeb() + + const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + + dispose() + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) + + it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))) + .toThrow(expect.objectContaining({ code: 'WEB_DUPLICATE_PROVIDER' })) + }) + + it('keeps search and fetch id namespaces independent', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('shared', available, () => Promise.resolve(searchResult('shared')))) + expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow() + }) + + it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { + const { ctx, web } = await mountWeb() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + }, { inject: ['web'] })) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + await fiber.dispose() + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) +}) + +describe('WebService execution resolution', () => { + it('throws WEB_PROVIDER_UNAVAILABLE when nothing is registered', async () => { + const { web } = await mountWeb() + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) + + it('throws WEB_PROVIDER_UNAVAILABLE when providers exist but none are usable', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) + + it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('throws WEB_PROVIDER_CONFIGURED_UNAVAILABLE for an unusable configured id', async () => { + const { web } = await mountWeb({ searchProvider: 'exa' }) + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + }) + + it('throws WEB_PROVIDER_AMBIGUOUS rather than picking by order', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' })) + }) + + it('runs the configured provider even when another usable provider is registered', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + }) + + it('ignores unusable providers when auto-selecting', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + }) + + it('does not let registration order change auto-selection', async () => { + const a = await mountWeb() + a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + + const b = await mountWeb() + b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + }) + + it('runs the selected provider and returns its result', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve( + searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }), + ))) + const result = await web.search({ query: 'q' }) + expect(result.providerId).toBe('exa') + expect(result.content).toBe('answer') + expect(result.sources).toEqual([{ url: 'https://a' }]) + }) + + it('propagates the abort signal to the provider', async () => { + const { web } = await mountWeb() + const seen: (AbortSignal | undefined)[] = [] + web.registerSearchProvider({ + id: 'exa', + status: () => available, + search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) }, + }) + const controller = new AbortController() + await web.search({ query: 'q' }, { signal: controller.signal }) + expect(seen[0]).toBe(controller.signal) + }) +}) + +describe('WebService maxResults enforcement', () => { + it('truncates sources and sets truncated when a provider over-returns', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }, { url: 'https://2' }, { url: 'https://3' }], + })))) + const result = await web.search({ query: 'q', maxResults: 2 }) + expect(result.sources).toHaveLength(2) + expect(result.truncated).toBe(true) + }) + + it('leaves truncated false when within the bound', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }], + })))) + const result = await web.search({ query: 'q', maxResults: 8 }) + expect(result.sources).toHaveLength(1) + expect(result.truncated).toBe(false) + }) + + it('does not bound when maxResults is omitted', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }, { url: 'https://2' }], + })))) + const result = await web.search({ query: 'q' }) + expect(result.sources).toHaveLength(2) + expect(result.truncated).toBe(false) + }) +}) + +describe('WebService fetch capability', () => { + it('resolves and runs the fetch provider independently of search', async () => { + const { web } = await mountWeb() + web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http'))) + const result = await web.fetch({ url: 'https://example.com' }) + expect(result.providerId).toBe('local-http') + expect(result.statusCode).toBe(200) + }) + + it('throws WEB_PROVIDER_UNAVAILABLE for fetch when no fetch provider is registered', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + await expect(web.fetch({ url: 'https://example.com' })).rejects.toThrow( + expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }), + ) + }) +}) + +describe('WebError', () => { + it('is a HarnessError carrying its code', () => { + const error = new WebError('boom', 'WEB_INVALID_URL') + expect(error.code).toBe('WEB_INVALID_URL') + expect(error.name).toBe('WebError') + }) +}) diff --git a/packages/support/ui-stdio/tsconfig.json b/packages/web/web/tsconfig.json similarity index 80% rename from packages/support/ui-stdio/tsconfig.json rename to packages/web/web/tsconfig.json index b333de0302..e9de391ba1 100644 --- a/packages/support/ui-stdio/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -17,14 +17,8 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../core/agent" - }, { "path": "../../llm/llm" - }, - { - "path": "../../core/session" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf47aa111b..c9b853b92a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -29,6 +32,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.8.0 + jsdom: + specifier: 29.1.1 + version: 29.1.1 knip: specifier: ^6.16.1 version: 6.16.1 @@ -41,6 +47,9 @@ importers: mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 + mermaid: + specifier: 11.16.0 + version: 11.16.0 micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 @@ -64,7 +73,7 @@ importers: version: 6.1.1(typescript@6.0.3)(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)) vitest: 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)) + version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(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/bash/bash: devDependencies: @@ -171,11 +180,18 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@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/core/agent-core: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@cordisjs/plugin-timer': specifier: workspace:^ @@ -255,6 +271,10 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/core/system-prompt: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-llm': specifier: workspace:^ @@ -278,6 +298,178 @@ 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/fs/fs: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@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/fs/fs-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@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/fs/fs-policy: + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@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/fs/tool-fs: + dependencies: + diff: + specifier: ^9.0.0 + version: 9.0.0 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-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/hooks/hook-protocol: + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@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/hooks/hooks-claude: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-hook-protocol': + specifier: workspace:^ + version: link:../hook-protocol + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@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/hooks/hooks-codex: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-hook-protocol': + specifier: workspace:^ + version: link:../hook-protocol + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + 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/llm/llm: devDependencies: '@deepseek-ai/dsh-brand': @@ -620,25 +812,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/support/ui-stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/todo/tool-todo: devDependencies: '@deepseek-ai/dsh-agent': @@ -684,6 +857,12 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -702,6 +881,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -726,6 +908,9 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -736,6 +921,18 @@ importers: specifier: ^3.17.0 version: 3.18.0 + packages/ui/app-boot: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': @@ -753,15 +950,18 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@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) @@ -775,6 +975,105 @@ 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/web/tool-web: + 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 + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../web-fetch-local + '@deepseek-ai/dsh-web-search-exa': + specifier: workspace:^ + version: link:../web-search-exa + 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/web/web: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + 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/web/web-fetch-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-deepseek: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-exa: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-perplexity: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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': @@ -899,6 +1198,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -908,6 +1210,21 @@ packages: zod: optional: true + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1059,6 +1376,16 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@cordisjs/plugin-include@1.0.4': resolution: {integrity: sha512-b1Hm1wmue0v7d/jayoXoBjCV2J14XWTL5yyDZEYeL2L9HgcyTq6JbCw99ozSbei98uAbkwq/pBhimsp/HsySeg==} peerDependencies: @@ -1075,6 +1402,42 @@ packages: peerDependencies: cordis: ^4.0.0-rc.5 + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@earendil-works/pi-ai@0.79.3': resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} engines: {node: '>=22.19.0'} @@ -1284,6 +1647,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1313,6 +1685,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1326,6 +1704,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} @@ -1859,6 +2240,99 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1871,6 +2345,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -1892,6 +2372,12 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1954,6 +2440,9 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitest/coverage-v8@4.1.8': resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: @@ -2037,6 +2526,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2071,6 +2563,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2086,6 +2586,12 @@ packages: '@cordisjs/plugin-loader': optional: true + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmokit@1.8.1: resolution: {integrity: sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==} @@ -2093,10 +2599,177 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2106,6 +2779,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2115,6 +2791,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2126,6 +2805,13 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -2142,9 +2828,16 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -2313,6 +3006,9 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2320,6 +3016,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2331,6 +3031,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2339,6 +3043,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -2347,6 +3054,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2355,6 +3069,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2384,6 +3101,15 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2411,14 +3137,27 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + knip@6.16.1: resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -2555,12 +3294,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2574,6 +3320,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -2607,6 +3358,12 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2761,9 +3518,15 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2786,6 +3549,12 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -2817,6 +3586,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2824,6 +3597,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown-plugin-dts@0.25.2: resolution: {integrity: sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg==} engines: {node: ^22.18.0 || >=24.0.0} @@ -2853,6 +3629,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -2860,6 +3642,13 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + schemastery@3.18.0: resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} @@ -2900,6 +3689,9 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2908,6 +3700,9 @@ packages: resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} engines: {node: '>=12'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2923,6 +3718,21 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.5: + resolution: {integrity: sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==} + + tldts@7.4.5: + resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2936,6 +3746,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -3018,6 +3832,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -3033,6 +3851,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: @@ -3122,6 +3944,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -3130,6 +3956,18 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3156,10 +3994,17 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3186,12 +4031,37 @@ snapshots: dependencies: zod: 4.4.3 + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 4.4.3 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -3460,6 +4330,14 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@7.1.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@chevrotain/types@11.1.2': {} + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6)': dependencies: '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) @@ -3477,6 +4355,30 @@ snapshots: cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) cosmokit: 1.8.1 + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -3637,6 +4539,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@google/genai@1.52.0': dependencies: google-auth-library: 10.7.0 @@ -3664,6 +4568,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3678,6 +4590,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@mistralai/mistralai@2.2.1': dependencies: ws: 8.21.0 @@ -4036,6 +4952,123 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4046,6 +5079,15 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 25.9.3 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.24.6 + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -4064,6 +5106,11 @@ snapshots: '@types/retry@0.12.0': {} + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': @@ -4157,6 +5204,11 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -4169,7 +5221,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 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)) + vitest: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(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)) '@vitest/expect@4.1.8': dependencies: @@ -4251,6 +5303,10 @@ snapshots: base64-js@1.5.1: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.1: {} birpc@4.0.0: {} @@ -4275,6 +5331,10 @@ snapshots: dependencies: readdirp: 4.1.2 + commander@7.2.0: {} + + commander@8.3.0: {} + convert-source-map@2.0.0: {} cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4): @@ -4293,6 +5353,14 @@ snapshots: '@cordisjs/plugin-include': link:vendor/include '@cordisjs/plugin-loader': link:vendor/loader + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmokit@1.8.1: {} cross-spawn@7.0.6: @@ -4301,12 +5369,212 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4315,6 +5583,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4323,6 +5595,12 @@ snapshots: dependencies: dequal: 2.0.3 + diff@9.0.0: {} + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 @@ -4333,8 +5611,12 @@ snapshots: empathic@2.0.1: {} + entities@8.0.0: {} + es-module-lexer@2.1.0: {} + es-toolkit@1.49.0: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -4555,10 +5837,18 @@ snapshots: google-logging-utils@1.1.3: {} + hachure-fill@0.5.2: {} + has-flag@4.0.0: {} hookable@6.1.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-proxy-agent@7.0.2: @@ -4575,20 +5865,32 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} + import-meta-resolve@4.2.0: {} + import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -4614,6 +5916,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-bigint@1.0.0: @@ -4642,10 +5970,16 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + knip@6.16.1: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -4662,6 +5996,10 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lefthook-darwin-arm64@2.1.9: optional: true @@ -4763,10 +6101,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + long@5.3.2: {} longest-streak@3.1.0: {} + lru-cache@11.5.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4783,6 +6125,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -4885,6 +6229,32 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.49.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -5174,8 +6544,14 @@ snapshots: package-manager-detector@1.6.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + partial-json@0.1.7: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-expression-matcher@1.5.0: {} @@ -5188,6 +6564,13 @@ snapshots: picomatch@4.0.4: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -5225,10 +6608,14 @@ snapshots: readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} retry@0.13.1: {} + robust-predicates@3.0.3: {} + rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.6 @@ -5287,12 +6674,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.1 '@rolldown/binding-win32-x64-msvc': 1.1.1 + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + schemastery@3.18.0: dependencies: '@standard-schema/spec': 1.1.0 @@ -5322,12 +6724,16 @@ snapshots: dependencies: anynum: 1.0.0 + stylis@4.4.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-color@9.4.0: {} + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -5339,6 +6745,20 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.5: {} + + tldts@7.4.5: + dependencies: + tldts-core: 7.4.5 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.5 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-algebra@2.0.0: {} @@ -5347,6 +6767,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-dedent@2.3.0: {} + tsconfck@3.1.6(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -5414,6 +6836,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.28.0: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -5437,6 +6861,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@14.0.1: {} + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(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)): dependencies: debug: 4.4.3 @@ -5462,7 +6888,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@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)): + vitest@4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(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)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 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)) @@ -5487,13 +6913,30 @@ snapshots: optionalDependencies: '@types/node': 25.9.3 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5507,8 +6950,12 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlchars@2.2.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index a669a8572d..f579169ab0 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -27,6 +27,8 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-logger-console', ]) +const localArtifactDirs = new Set(['node_modules']) + /** The subset of package.json fields this constraint check cares about. */ interface PackageManifest { name?: string @@ -64,10 +66,13 @@ function packageDirs(base: string, depth: number): string[] { if (depth === 1) { return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) + .filter(entry => !localArtifactDirs.has(entry.name)) + .filter(entry => existsSync(join(root, base, entry.name, 'package.json'))) .map(entry => join(base, entry.name)) } return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) + .filter(entry => !localArtifactDirs.has(entry.name)) .flatMap(group => packageDirs(join(base, group.name), depth - 1)) } @@ -177,6 +182,7 @@ function checkHierarchyShape(): string[] { } for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) { if (!pkg.isDirectory()) continue + if (localArtifactDirs.has(pkg.name)) 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/<group>/<pkg>, no deeper nesting`) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json new file mode 100644 index 0000000000..b43f5fa8fc --- /dev/null +++ b/scripts/doc-budgets.manifest.json @@ -0,0 +1,11 @@ +{ + "AGENTS.md": 1575, + "docs/AGENTS.md": 1315, + "docs/architecture.md": 1630, + "docs/cordis-primer.md": 550, + "docs/defensive-patterns.md": 550, + "docs/testing.md": 800, + "examples/AGENTS.md": 610, + "packages/AGENTS.md": 450, + "packages/README.md": 605 +} diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 7422d6ac77..c6a5510948 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -9,13 +9,15 @@ * opts out with an explicit ` ```ts ignore-check ` info string — the opt-out * is visible in the source, and this script reports the ratio so the escape * hatch can't quietly become the norm. A third info string, - * doc-typecheck.ts recognizes two more fence variants and skips both (each is a - * separately-checked category, not an unchecked sketch, so neither counts in the - * opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that - * `scripts/verify-type-equiv.ts` drift-checks, and ` ```ts cordis-catalog ` is a + * doc-typecheck.ts recognizes three more fence variants and skips all three (each + * is a separately-checked category, not an unchecked sketch, so none counts in + * the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that + * `scripts/verify-type-equiv.ts` drift-checks, ` ```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). + * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate), and + * ` ```ts persistence-catalog ` is a generated log-event payload fragment in the + * persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`). * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -43,8 +45,12 @@ const root = resolve(import.meta.dirname, '..') * (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. + * - `persistence-catalog` (` ```ts persistence-catalog `) — a generated + * log-event payload fragment in the persistence catalog. Same treatment for + * the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its + * `--check` freshness gate. */ -type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' +type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' /** One extracted code block. */ interface Block { @@ -55,7 +61,8 @@ interface Block { code: string } -/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */ +/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog / + * ts persistence-catalog block from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') const lines = text.split('\n') @@ -82,7 +89,8 @@ function extractBlocks(absPath: string): Block[] { : info === 'ts ignore-check' ? 'ignore' : info === 'ts type-equiv' ? 'type-equiv' : info === 'ts cordis-catalog' ? 'cordis-catalog' - : null + : info === 'ts persistence-catalog' ? 'persistence-catalog' + : null if (kind) open = { line: i + 1, kind, body: [] } }) return blocks @@ -131,11 +139,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` 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. +// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified +// elsewhere (verify-type-equiv.ts and each catalog generator's `--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) { @@ -171,7 +179,7 @@ try { const ratio = ignored.length / ratioDenominator 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).`) + console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/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 index de477d8ba3..acb6579272 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -1,32 +1,46 @@ /** - * Generate (and verify) the cordis events + services catalog in - * docs/cordis-catalog/events-and-services.md. + * Generate (and verify) the cordis events and services catalogs in + * docs/cordis-catalog/events.md and docs/cordis-catalog/services.md. * - * The catalog is the WIRING-axis reference: every cordis event a plugin can - * listen to (exact signature + dispatch mode) and every `ctx.<key>` service it - * can call (exact public interface). It complements the core-data-structures - * catalog (the VOCABULARY axis — the types these signatures move around). + * The two pages are the WIRING-axis reference, one axis each: every cordis + * event a plugin can listen to (exact signature + dispatch mode) and every + * `ctx.<key>` service it can call (exact public interface). They complement the + * core-data-structures catalog (the VOCABULARY axis — the types these + * signatures move around). * - * 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`. + * The catalogs are FULLY GENERATED from source — never hand-edit them. 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 files + * are build artifacts 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 - * is stale (CI / pre-push gate) + * `tsx scripts/gen-cordis-catalog.ts` → write both catalogs + * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed + * catalog 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|serial` tag * — the generator hard-errors on a missing tag, and where the signature shape is * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) - * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED + * it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag, + * the walk enforces JSDoc COMPLETENESS on the whole harness surface (the + * jsdoc-completeness-gate RFC): every event and public service method carries + * description prose; every payload parameter has a non-empty `@param` (`this` + * receivers and the trailing waterfall `next` are exempt — next's semantics are + * documented once by the mode); a service method with a non-`void`/ + * `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT + * return type annotation (a pure-AST walk cannot classify an inferred return); + * a stale `@param` naming no real parameter errors. Violations aggregate into + * ONE error listing every offender. The tags are enforcement-only: parseJsDoc + * stops prose at the first block tag, so they never change the rendered + * catalog. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author * also sees; it is rendered tersely (name + one-line + source pointer) from a * curated table in this script, NOT elevated to the harness tier's prominence. @@ -41,7 +55,8 @@ import { resolve } from 'node:path' import ts from 'typescript' const root = resolve(import.meta.dirname, '..') -const OUT = 'docs/cordis-catalog/events-and-services.md' +const OUT_EVENTS = 'docs/cordis-catalog/events.md' +const OUT_SERVICES = 'docs/cordis-catalog/services.md' /** The fenced-block info string for generated signature blocks (skipped by * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ @@ -52,11 +67,15 @@ type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' /** * 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). + * core-data-structures page that documents it (path relative to the catalogs' + * 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. + * TODO(catalog-type-links): add a verifier or generator for link-map coverage + * so new hook-era decision types like `PromptDecision` / `PreToolDecision` do + * not silently appear in signatures without a "Types:" link. */ const LINK_MAP: Record<string, string> = { Agent: 'core.md', @@ -75,6 +94,15 @@ const LINK_MAP: Record<string, string> = { BashRunResult: 'bash.md', BashTask: 'bash.md', BashTaskRead: 'bash.md', + FsEditOutcome: 'filesystem.md', + FsEditRequest: 'filesystem.md', + FsInfo: 'filesystem.md', + FsTarget: 'filesystem.md', + FsVersion: 'filesystem.md', + FsWriteIntent: 'filesystem.md', + FsWriteOutcome: 'filesystem.md', + FsPolicyExec: 'filesystem.md', + FileReadOutcome: 'filesystem.md', } /** One harness event, extracted from an `interface Events` block. */ @@ -135,8 +163,10 @@ function rawJsDoc(text: string, node: ts.Node): string { * present). Output obeys the repo's markdown conventions so the generated file * passes verify-md-wrap: each prose paragraph collapses to ONE physical line, * and a `-` bullet list is preserved with each item on its own single line - * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines - * other than `@mode` end the current prose run. + * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description + * prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and + * their continuation lines are never prose, so `@param`/`@returns` blocks are + * invisible to the rendered catalog. */ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { const inner = raw @@ -145,6 +175,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { .split('\n') .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) let mode: Mode | null = null + let inTags = false const blocks: string[] = [] let para: string[] = [] let list: string[] = [] @@ -166,8 +197,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { } for (const line of inner) { const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) - if (m) { mode = m[1] as Mode; continue } - if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose + if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue } + if (line.startsWith('@')) { flushPara(); inTags = true; continue } + if (inTags) continue // block-tag territory: continuations are never prose if (line.trim() === '') { flushPara(); continue } if (/^-\s+/.test(line)) { // A list item starts: a pending paragraph (e.g. an intro line directly @@ -185,6 +217,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { return { doc, mode } } +/** + * Parse the block tags of a raw JSDoc comment for the completeness checks: + * every `@param name — description` entry plus the `@returns` description. + * Standard JSDoc block-tag semantics — a tag's description runs across + * continuation lines until the next tag or a blank line, and the `-`/`—` + * separator after a param name is optional. `[name]` optional-brackets unwrap + * to `name`. Rendering never sees these: parseJsDoc stops prose at the first + * block tag. + */ +function parseTags(raw: string): { params: Map<string, string>; returns: string | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const params = new Map<string, string>() + let returns: string | null = null + let sink: ((text: string) => void) | null = null + for (const line of inner) { + const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) + if (param) { + const name = (param[1] ?? '').replace(/^\[|\]$/g, '') + let acc = param[2] ?? '' + params.set(name, acc) + sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) } + continue + } + const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) + if (ret) { + let acc = ret[1] ?? '' + returns = acc + sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc } + continue + } + if (line.startsWith('@') || line.trim() === '') { sink = null; continue } + sink?.(line.trim()) + } + return { params, returns } +} + +/** + * Throw one aggregate error for every completeness violation a walk collected. + * Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a + * remediation pass sees the whole list at once instead of replaying the gate + * once per offender. + */ +function reportViolations(violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n` + + violations.map(v => ` ${v}`).join('\n'), + ) +} + /** Find the `declare module 'cordis'` body in a source file, or null. */ function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { for (const stmt of sf.statements) { @@ -203,10 +289,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() } -/** Walk every harness `interface Events` block and extract its events. - * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +/** Walk every harness `interface Events` block and extract its events, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a missing/ + * contradicted `@mode`, missing description prose, or an undocumented payload + * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -220,33 +309,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { if (!ts.isMethodSignature(member)) continue const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) const signature = memberSignature(member, sf) - const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) + const raw = rawJsDoc(text, member) + const { doc, mode } = parseJsDoc(raw) const src = pointer(rel, sf, member) + const where = `event '${name}' (${src})` if (!mode) { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) + violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } // Conclusive structural check: a trailing `next: () => …` parameter is a // waterfall. (emit vs parallel vs serial is not structurally // distinguishable, so it is trusted from the tag.) const last = member.parameters.at(-1) const hasNext = !!last && last.name.getText(sf) === 'next' - if (hasNext && mode !== 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) + if (mode && hasNext && mode !== 'waterfall') { + violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) } - if (!hasNext && mode === 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) + if (mode && !hasNext && mode === 'waterfall') { + violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) } - entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) + // Payload parameters need a non-empty @param each. Exempt the `this` + // receiver annotation (not payload) and the trailing waterfall `next` + // (mode machinery, documented once by @mode semantics). Documenting an + // exempt parameter anyway is allowed — only absence is checked. + const { params } = parseTags(raw) + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this' || (hasNext && p === last)) continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) } } } + reportViolations(violations) return entries } -/** Walk every harness `interface Context` block + its service class. +/** Walk every harness `interface Context` block + its service class, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a class or public + * method without JSDoc prose, an undocumented parameter, a stale `@param`, a + * missing `@returns` on a non-void method, or an inferred (unannotated) return + * type the pure-AST walk cannot classify. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -272,6 +391,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { ) if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue @@ -288,17 +409,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members methods.push(memberSignature(member, sf)) + const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` + const raw = rawJsDoc(text, member) + if (!raw) { violations.push(`${where} has no JSDoc.`); continue } + if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) + const { params, returns } = parseTags(raw) + // Every parameter needs a non-empty @param; a `this` receiver + // annotation is not payload and is exempt. + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this') continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + // A non-void result needs a non-empty @returns. The return type must be + // ANNOTATED: a pure-AST walk cannot classify an inferred return. On a + // `void`/`Promise<void>` method @returns stays optional (resolution + // timing can be worth documenting), never required. + const rt = member.type?.getText(sf).replace(/\s+/g, ' ') + if (rt === undefined) { + violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`) + } else if (!/^(void|Promise<void>)$/.test(rt)) { + if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`) + else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`) + } } entries.push({ key, type, abstract, - doc: parseJsDoc(rawJsDoc(text, cls)).doc, + doc: clsDoc, methods, source: pointer(rel, sf, cls), }) } } + reportViolations(violations) return entries.sort((a, b) => a.key.localeCompare(b.key)) } @@ -355,7 +511,7 @@ function typeLinks(signature: string): string { /** Render one harness event entry. */ function renderEvent(e: EventEntry): string[] { - const out = [`#### \`${e.name}\` — ${e.mode}`, ''] + const out = [`### \`${e.name}\` — ${e.mode}`, ''] if (e.doc) out.push(e.doc, '') out.push('```' + FENCE, e.signature, '```', '') const links = typeLinks(e.signature) @@ -367,7 +523,7 @@ function renderEvent(e: EventEntry): string[] { /** Render one harness service entry. */ function renderService(s: ServiceEntry): string[] { const kind = s.abstract ? ' (abstract seam)' : '' - const out = [`### \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] + const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] if (s.doc) out.push(s.doc, '') if (s.methods.length) { out.push('```' + FENCE, ...s.methods, '```', '') @@ -378,51 +534,71 @@ function renderService(s: ServiceEntry): string[] { return out } -/** Render the full catalog (pure, deterministic given sorted inputs). */ -function render(events: EventEntry[], services: ServiceEntry[]): string { +/** The shared generated-file banner comment. */ +const BANNER = [ + '<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.', + ' Run `pnpm run gen-cordis-catalog` to regenerate. -->', + '', +] + +/** The shared GENERATED + freshness-gate + fence notice paragraph. */ +const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.' + +/** Render the events catalog (pure, deterministic given sorted inputs). */ +function renderEvents(events: EventEntry[]): string { const lines: string[] = [ - '<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.', - ' Run `pnpm run gen-cordis-catalog` to regenerate. -->', + ...BANNER, + '# Cordis Events Catalog', '', - '# Cordis Events & Services Catalog', + 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', '', - '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.<key>` 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.', + GATE_NOTICE, '', - 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.', '', - '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; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', + 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', '', ] const scopes = [...new Set(events.map(e => e.scope))].sort() for (const scope of scopes) { - lines.push(`### \`${scope}/*\``, '') + 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', + '## Inherited events (cordis core + loader/hmr/timer)', '', - 'The `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', - '', - ) - 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', + 'The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier\'s prominence.', '', ) for (const e of INHERITED_EVENTS) { lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`) } - lines.push('', '### Inherited `ctx` members', '') + lines.push('') + return lines.join('\n') +} + +/** Render the services catalog (pure, deterministic given sorted inputs). */ +function renderServices(services: ServiceEntry[]): string { + const lines: string[] = [ + ...BANNER, + '# Cordis Services Catalog', + '', + 'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', + '', + GATE_NOTICE, + '', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.', + '', + ] + for (const s of services) lines.push(...renderService(s)) + lines.push( + '## Inherited `ctx` members (cordis core + loader/hmr/timer)', + '', + 'The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier\'s prominence.', + '', + ) for (const s of INHERITED_SERVICES) { lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`) } @@ -430,31 +606,38 @@ function render(events: EventEntry[], services: ServiceEntry[]): string { 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. */ +/** CLI entry: `--write` (default) writes both catalogs, `--check` fails if + * either is stale. Guarded behind an entry-point check so importing this module + * for tests neither regenerates the committed files nor calls process.exit. */ function main(): void { - const content = render(collectEvents(), collectServices()) + const outputs: [string, string][] = [ + [OUT_EVENTS, renderEvents(collectEvents())], + [OUT_SERVICES, renderServices(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 + const stale: string[] = [] + for (const [out, content] of outputs) { + 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) stale.push(out) } - if (committed === content) { - console.log(`gen-cordis-catalog: ${OUT} is up to date.`) + if (stale.length === 0) { + console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`) process.exit(0) } - console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`) + console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`) process.exit(1) } - writeFileSync(resolve(root, OUT), content) - console.log(`gen-cordis-catalog: wrote ${OUT}.`) + for (const [out, content] of outputs) writeFileSync(resolve(root, out), content) + console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`) } // Run only when invoked as a script, not when imported by a test. diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts new file mode 100644 index 0000000000..4f90b79e8e --- /dev/null +++ b/scripts/gen-doc-graphs.ts @@ -0,0 +1,776 @@ +/** + * Generate (and verify) the relationship-diagram docs. + * + * This is the relationship layer above the existing catalogs: + * - module-graph.md answers "which packages depend on which packages?" + * - cordis-catalog/ answers "which events and services exist?" + * - tool-catalog/ answers "which tools does the model see?" + * - generated relationship diagrams answer "how do those pieces fit together?" + * + * Generated pages discover the enumerable facts from source. Hybrid pages use + * discovered inventory plus small manifests for policy that source cannot infer + * (for example, whether a package is an implementation or consumer in a seam). + * Curated pages are still emitted here so the graph docs are one regenerated unit, + * but their diagrams intentionally explain flow and ownership rather than + * pretending to enumerate every source edge. + * + * `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs + * `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale + */ + +import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, relative, resolve } from 'node:path' +import ts from 'typescript' +import { collectEvents, collectServices } from './gen-cordis-catalog.ts' + +const root = resolve(import.meta.dirname, '..') +const SCOPE = '@deepseek-ai/dsh-' + +interface PkgJson { + name: string + peerDependencies?: Record<string, string> +} + +interface Pkg { + short: string + name: string + group: string + rel: string + deps: string[] +} + +interface GraphDoc { + rel: string + content: string +} + +interface ServiceRole { + key: string + pkg: string + title: string + mode: 'core' | 'seam' | 'bundle' + implementations?: string[] + consumers?: string[] + companions?: string[] + note: string +} + +interface ExamplePlugin { + id: string + name: string +} + +interface EventRelation { + dispatchers: Map<string, Set<string>> + listeners: Set<string> +} + +const GROUP_ORDER = [ + 'util', + 'llm', + 'core', + 'bash', + 'fs', + 'compact', + 'subagent', + 'web', + 'todo', + 'hooks', + 'session-persistence', + 'support', + 'ui', +] + +const SERVICE_ROLES: ServiceRole[] = [ + { + key: 'llm', + pkg: 'llm', + title: 'LLM adapter registry', + mode: 'seam', + implementations: ['llm-deepseek', 'llm-pi-ai', 'llm-replay'], + consumers: ['agent-loop', 'compact-basic'], + note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.', + }, + { + key: 'sessions', + pkg: 'session', + title: 'In-memory session store', + mode: 'core', + consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'], + note: 'Owns append-only Session instances and emits the durable session event feed.', + }, + { + key: 'sessionPersistence', + pkg: 'session-persistence', + title: 'Durable session persistence seam', + mode: 'seam', + implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], + consumers: ['agent-loop', 'acp'], + note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', + }, + { + key: 'systemPrompt', + pkg: 'system-prompt', + title: 'System prompt assembly registry', + mode: 'core', + consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-web'], + note: 'Collects prompt sections and model-facing tool schemas for each step.', + }, + { + key: 'tools', + pkg: 'tools', + title: 'Tool registry and execution waterfall', + mode: 'core', + consumers: ['agent-loop', 'tool-bash', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'], + note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.', + }, + { + key: 'agents', + pkg: 'agent', + title: 'Agent registry', + mode: 'core', + consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'], + note: 'Owns live Agent handles and the create/resume factory seam.', + }, + { + key: 'agentLoop', + pkg: 'agent-loop', + title: 'Concrete loop driver', + mode: 'bundle', + consumers: ['agent-core'], + note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.', + }, + { + key: 'bash', + pkg: 'bash', + title: 'Bash executor seam', + mode: 'seam', + implementations: ['bash-local'], + consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], + note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.', + }, + { + key: 'fs', + pkg: 'fs', + title: 'Filesystem provider seam', + mode: 'seam', + implementations: ['fs-local'], + consumers: ['tool-fs'], + companions: ['fs-policy'], + note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.', + }, + { + key: 'compact', + pkg: 'compact', + title: 'Compaction seam', + mode: 'seam', + implementations: ['compact-basic'], + consumers: ['compact-basic'], + note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.', + }, + { + key: 'subagents', + pkg: 'subagent', + title: 'Subagent provider registry', + mode: 'seam', + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'], + consumers: ['tool-subagent'], + note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.', + }, + { + key: 'web', + pkg: 'web', + title: 'Web access provider registry', + mode: 'seam', + implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-local'], + consumers: ['tool-web'], + note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.', + }, +] + +const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [ + // Subagent lifecycle events intentionally bypass ctx.emit and call + // ctx.events.dispatch directly so one throwing listener cannot starve later + // listeners or strand an already-started child run. + { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' }, + { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' }, +] + +function generatedHeader(title: string): string[] { + return [ + '<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.', + ' Run `pnpm run gen-doc-graphs` to regenerate. -->', + '', + `# ${title}`, + '', + ] +} + +function maintenanceFooter(source: string): string[] { + return [`Maintenance mode: ${source}.`, ''] +} + +function graphIndexLink(rel: string): string { + return relative('docs', rel).replaceAll('\\', '/') +} + +function linkFromDoc(docRel: string, targetRel: string): string { + return relative(dirname(docRel), targetRel).replaceAll('\\', '/') +} + +function collectPackages(): Pkg[] { + const pkgs: Pkg[] = [] + for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) { + const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson + if (!json.name.startsWith(SCOPE)) continue + const [, group, leaf] = rel.split('/') + if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`) + const deps = Object.keys(json.peerDependencies ?? {}) + .filter(dep => dep.startsWith(SCOPE)) + .map(dep => dep.slice(SCOPE.length)) + .sort() + pkgs.push({ + short: json.name.slice(SCOPE.length), + name: json.name, + group, + rel: dirname(rel), + deps, + }) + } + return topoSort(pkgs) +} + +function topoSort(pkgs: Pkg[]): Pkg[] { + const remaining = new Map(pkgs.map(p => [p.short, p])) + const placed = new Set<string>() + const out: Pkg[] = [] + while (remaining.size > 0) { + const ready = [...remaining.values()] + .filter(pkg => pkg.deps.every(dep => placed.has(dep))) + .sort(comparePackages) + if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`) + for (const pkg of ready) { + out.push(pkg) + placed.add(pkg.short) + remaining.delete(pkg.short) + } + } + return out +} + +function comparePackages(a: Pkg, b: Pkg): number { + const groupA = GROUP_ORDER.indexOf(a.group) + const groupB = GROUP_ORDER.indexOf(b.group) + const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA + const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB + return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short) +} + +function nodeId(prefix: string, value: string): string { + return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}` +} + +function escLabel(value: string): string { + return value.replace(/"/g, '\\"') +} + +function mermaidCode(value: string): string { + return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>` +} + +function repoLink(path: string, label: string, up = '..'): string { + return `[${label}](${up}/${path})` +} + +function sourceLink(source: string, up = '..'): string { + return repoLink(source.split(':')[0] ?? source, `\`${source}\``, up) +} + +function pkgLink(pkg: Pkg | undefined, fallback: string, up = '..'): string { + return pkg ? repoLink(pkg.rel, `\`${pkg.short}\``, up) : `\`${fallback}\`` +} + +function pkgList(names: string[] | undefined, pkgsByShort: Map<string, Pkg>): string { + if (!names || names.length === 0) return '-' + return names.map(name => pkgLink(pkgsByShort.get(name), name)).join(', ') +} + +function tableCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>') +} + +function assertServiceRolesComplete(): void { + const discovered = new Set(collectServices().map(service => service.key)) + const classified = new Set(SERVICE_ROLES.map(role => role.key)) + const missing = [...discovered].filter(key => !classified.has(key)).sort() + const stale = [...classified].filter(key => !discovered.has(key)).sort() + if (missing.length || stale.length) { + throw new Error([ + missing.length ? `missing service role classification: ${missing.join(', ')}` : '', + stale.length ? `stale service role classification: ${stale.join(', ')}` : '', + ].filter(Boolean).join('; ')) + } +} + +function renderCapabilitySeams(pkgs: Pkg[]): string { + assertServiceRolesComplete() + const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) + const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard' + const nodes = new Map<string, string>() + const edges = new Set<string>() + const companionEdges = new Set<string>() + const addNode = (id: string, label: string): void => { + if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`) + } + const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) } + const lines = generatedHeader('Capability Seams And Core Services') + lines.push( + 'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.', + '', + '```mermaid', + 'flowchart LR', + ) + for (const role of SERVICE_ROLES) { + const svc = nodeId('svc', role.key) + const owner = nodeId('pkg', role.pkg) + addNode(owner, role.pkg) + addNode(svc, `ctx.${role.key}<br/>${role.title}`) + addEdge(owner, svc) + for (const impl of role.implementations ?? []) { + addNode(nodeId('pkg', impl), impl) + addEdge(nodeId('pkg', impl), svc) + } + for (const consumer of role.consumers ?? []) { + addNode(nodeId('pkg', consumer), consumer) + addEdge(svc, nodeId('pkg', consumer)) + } + for (const companion of role.companions ?? []) { + addNode(nodeId('pkg', companion), companion) + companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`) + } + } + lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort()) + lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |') + for (const role of SERVICE_ROLES) { + lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`) + } + lines.push('', ...maintenanceFooter(maintenance)) + return lines.join('\n') +} + +function parseExampleCordis(rel: string): ExamplePlugin[] { + const text = readFileSync(resolve(root, rel), 'utf8') + const plugins: ExamplePlugin[] = [] + let current: { id: string; name?: string } | null = null + const flush = (): void => { + if (current?.name) plugins.push({ id: current.id, name: current.name }) + } + for (const line of text.split('\n')) { + const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) + if (id?.[1] !== undefined) { + flush() + current = { id: stripYamlScalar(id[1]) } + continue + } + const name = /^\s+name:\s+(.+?)\s*$/.exec(line) + if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1]) + } + flush() + return plugins +} + +function stripYamlScalar(value: string): string { + return value.trim().replace(/^['"]|['"]$/g, '') +} + +const APP_EXAMPLES = [ + { + id: 'echo', + rel: 'examples/echo-agent/composition.md', + title: 'Echo Agent App Composition', + label: 'examples/echo-agent', + config: 'examples/echo-agent/cordis.yml', + summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.', + }, + { + id: 'coding', + rel: 'examples/coding-agent/composition.md', + title: 'Coding Agent App Composition', + label: 'examples/coding-agent', + config: 'examples/coding-agent/cordis.yml', + summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', + }, + { + id: 'acp', + rel: 'examples/acp-agent/composition.md', + title: 'ACP Agent App Composition', + label: 'examples/acp-agent', + config: 'examples/acp-agent/cordis.yml', + summary: 'The ACP demo exposes the same agent spine over JSON-RPC stdio, with no stdout logger and no pre-created agent; clients create sessions through the ACP bridge.', + }, +] + +type AppExample = typeof APP_EXAMPLES[number] + +function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void { + const agentCore = nodeId('bundle', 'agent_core') + const jsonl = nodeId('bundle', 'jsonl') + lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`) + lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) + if (pluginName === '@deepseek-ai/dsh-stdio-agent') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`) + } else if (pluginName === '@deepseek-ai/dsh-acp-agent') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`) + } + lines.push( + ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`, + ` ${agentCore} --> ${nodeId('spine', 'sessions')}["ctx.sessions"]`, + ` ${agentCore} --> ${nodeId('spine', 'tools')}["ctx.tools + tool-bash"]`, + ` ${agentCore} --> ${nodeId('spine', 'loop')}["ctx.agents + ctx.agentLoop"]`, + ) +} + +function renderAppComposition(example: AppExample): string { + const plugins = parseExampleCordis(example.config) + const maintenance = 'hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source' + const lines = generatedHeader(example.title) + lines.push( + example.summary, + '', + '```mermaid', + 'flowchart LR', + ` cfg["${escLabel(example.label)}<br/>cordis.yml"]`, + ) + for (const plugin of plugins) { + const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) + lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`) + lines.push(` cfg --> ${pluginNode}`) + if (plugin.name === '@deepseek-ai/dsh-stdio-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') { + renderAppExpansion(lines, pluginNode, plugin.name) + } + } + lines.push( + '```', + '', + '| Plugin id | Package / module |', + '| --- | --- |', + ...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`), + '', + `Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`, + ) + lines.push('', ...maintenanceFooter(maintenance)) + return lines.join('\n') +} + +function collectEventRelations(): Map<string, EventRelation> { + const out = new Map<string, EventRelation>() + const ensure = (event: string): EventRelation => { + const existing = out.get(event) + if (existing) return existing + const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() } + out.set(event, next) + return next + } + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) { + const [, , leaf] = rel.split('/') + if (leaf === undefined) continue + const text = readFileSync(resolve(root, rel), 'utf8') + const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const method = node.expression.name.text + if (!isCordisContextReceiver(node.expression, sf)) { + ts.forEachChild(node, visit) + return + } + if (method === 'on') { + const event = eventArg(node.arguments, method) + if (event) ensure(event).listeners.add(leaf) + } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') { + const event = eventArg(node.arguments, method) + if (event) { + const relation = ensure(event) + const methods = relation.dispatchers.get(leaf) ?? new Set<string>() + methods.add(method) + relation.dispatchers.set(leaf, methods) + } + } + } + ts.forEachChild(node, visit) + } + visit(sf) + } + for (const entry of DYNAMIC_EVENT_DISPATCHERS) { + const relation = ensure(entry.event) + const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>() + methods.add(entry.method) + relation.dispatchers.set(entry.pkg, methods) + } + return out +} + +function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { + const target = expr.expression.getText(sf) + return target === 'ctx' || target === 'this.ctx' +} + +function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined { + if (method === 'waterfall') { + const arg = args.find(ts.isStringLiteralLike) + return arg?.text + } + const first = args[0] + return first && ts.isStringLiteralLike(first) ? first.text : undefined +} + +function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string { + if (map.size === 0) return '-' + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pkg, methods]) => `${pkgLink(pkgsByShort.get(pkg), pkg)} (${[...methods].sort().map(m => `\`${m}\``).join(', ')})`) + .join(', ') +} + +function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>): string { + if (listeners.size === 0) return '-' + return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ') +} + +function renderEventRelations(pkgs: Pkg[]): string { + const events = collectEvents() + const relations = collectEventRelations() + const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) + const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`' + const lines = generatedHeader('Event Producer And Consumer Matrix') + lines.push( + 'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.', + '', + '| Event | Mode | Declared in | Dispatchers | Listeners |', + '| --- | --- | --- | --- | --- |', + ) + for (const event of [...events].sort((a, b) => a.name.localeCompare(b.name))) { + const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() } + lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) + } + const declared = new Set(events.map(event => event.name)) + const extra = [...relations.keys()].filter(event => !declared.has(event)).sort() + if (extra.length > 0) { + lines.push('', '## Non-harness or undeclared event strings seen in package source', '', '| Event string | Dispatchers | Listeners |', '| --- | --- | --- |') + for (const event of extra) { + const relation = relations.get(event) + if (!relation) continue + lines.push(`| \`${event}\` | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) + } + } + lines.push('', ...maintenanceFooter(maintenance)) + return lines.join('\n') +} + +function renderLifecycle(): string { + const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog' + return [ + ...generatedHeader('Agent Turn And Step Lifecycle'), + 'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.', + '', + '```mermaid', + 'sequenceDiagram', + ' participant User', + ' participant Agent', + ' participant Driver', + ' participant Hooks as hook listeners', + ' participant Prompt as ctx.systemPrompt', + ' participant LLM as ctx.llm', + ' participant Tools as ctx.tools', + ' participant Session', + ' participant Persistence', + ' participant SDK as UI or SDK listener', + ' User->>Agent: send(content)', + ` Agent-->>SDK: ${mermaidCode('agent/queued')}`, + ' Agent->>Driver: queued work wakes driver', + ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, + ` Driver->>Session: ${mermaidCode('turn/start')}`, + ` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`, + ' Hooks-->>Driver: allow, block, or add context', + ` Driver->>Session: ${mermaidCode('user/message')} or rejected ${mermaidCode('turn/end')}`, + ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`, + ` Driver-->>Driver: ${mermaidCode('agent/pre-step')} serial checkpoint`, + ` Driver->>Session: ${mermaidCode('step/start')}`, + ` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`, + ' LLM-->>Driver: StreamChunk*', + ` Driver->>Session: ${mermaidCode('assistant/chunk')}*`, + ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`, + ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`, + ` Driver->>Session: ${mermaidCode('assistant/message')}`, + ` Driver->>Session: ${mermaidCode('tool/call')}`, + ' Driver->>Tools: execute through pre and post waterfalls', + ' Tools-->>Session: tool-owned events when applicable', + ` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`, + ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`, + ` Driver->>Session: ${mermaidCode('turn/end')}`, + ` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`, + ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`, + '```', + '', + 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', + '', + ...maintenanceFooter(maintenance), + ].join('\n') +} + +function renderToolPipeline(): string { + const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' + return [ + ...generatedHeader('Tool Execution Pipeline'), + 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute` and `tools/post-execute` waterfalls.', + '', + '```mermaid', + 'flowchart TD', + ' model["Assistant message contains tool-call block"]', + ` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`, + ' presentCall["UI pending card<br/>presentCall(args)"]', + ` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`, + ' denied["deny or ask<br/>tool body skipped"]', + ' toolBody["Registered tool execute() body"]', + ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`, + ` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}"]`, + ` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`, + ' context["Buffered additionalContext<br/>context/message after all tool results"]', + ` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`, + ' presentResult["UI completed card<br/>presentResult(args, result)"]', + ' model --> toolCall', + ' toolCall --> presentCall', + ' toolCall --> pre', + ' pre -->|allow| toolBody', + ' pre -->|deny or ask| denied', + ' denied --> post', + ' toolBody --> fsGate', + ' fsGate --> toolBody', + ' toolBody --> owned', + ' toolBody --> post', + ' post --> context', + ' post --> toolResult', + ' toolResult --> presentResult', + '```', + '', + 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.', + '', + ...maintenanceFooter(maintenance), + ].join('\n') +} + +function renderSnapshotReplay(): string { + const maintenance = 'curated Mermaid sequence based on the snapshot test harness' + return [ + ...generatedHeader('ACP Snapshot Replay'), + 'This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.', + '', + '```mermaid', + 'sequenceDiagram', + ' participant Recorder as Real API recording', + ' participant Fixture as snapshot fixture', + ' participant Workspace', + ' participant Replay as llm-replay adapter', + ' participant ACP as acp-agent subprocess', + ' participant Golden as stdout golden', + ' Recorder->>Fixture: session.jsonl + workspace inputs', + ' Fixture->>Workspace: seed files and hook configs', + ' Fixture->>Replay: recorded StreamChunk script', + ` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`, + ' ACP->>Workspace: bash, fs, and hook side effects', + ' ACP->>Golden: normalized sessionUpdate stream', + ' Golden-->>ACP: diff must be empty', + '```', + '', + 'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.', + '', + ...maintenanceFooter(maintenance), + ].join('\n') +} + +function renderDocs(): GraphDoc[] { + const pkgs = collectPackages() + const docs: GraphDoc[] = [ + { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) }, + ...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })), + { rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs) }, + { rel: 'docs/agent-lifecycle.md', content: renderLifecycle() }, + { rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() }, + { rel: 'packages/ui/acp/snapshot-replay.md', content: renderSnapshotReplay() }, + ] + docs.unshift({ rel: 'docs/graph-atlas.md', content: renderIndex(docs) }) + return docs +} + +function renderIndex(docs: GraphDoc[]): string { + const labels: Record<string, string> = { + 'docs/capability-seams.md': 'capability seams and core services', + 'examples/echo-agent/composition.md': 'echo-agent app composition', + 'examples/coding-agent/composition.md': 'coding-agent app composition', + 'examples/acp-agent/composition.md': 'acp-agent app composition', + 'docs/event-producer-consumer.md': 'event producer/consumer matrix', + 'docs/agent-lifecycle.md': 'agent turn and step lifecycle', + 'docs/tool-execution-pipeline.md': 'tool execution pipeline', + 'packages/ui/acp/snapshot-replay.md': 'ACP snapshot replay', + } + const modes: Record<string, string> = { + 'docs/capability-seams.md': 'hybrid generated', + 'examples/echo-agent/composition.md': 'hybrid generated', + 'examples/coding-agent/composition.md': 'hybrid generated', + 'examples/acp-agent/composition.md': 'hybrid generated', + 'docs/event-producer-consumer.md': 'hybrid generated', + 'docs/agent-lifecycle.md': 'curated', + 'docs/tool-execution-pipeline.md': 'curated', + 'packages/ui/acp/snapshot-replay.md': 'curated', + } + const rows = [ + '| [module dependency graph](module-graph.md) | `generated` |', + '| [tool schema catalog and package map](tool-catalog/tools.md) | `generated` |', + ...docs.map((doc) => { + const link = graphIndexLink(doc.rel) + return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |` + }), + ] + const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode' + return [ + ...generatedHeader('Documentation Graph Index'), + 'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog/](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md).', + '', + 'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).', + '', + '| Graph | Mode |', + '| --- | --- |', + ...rows, + '', + 'Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`.', + '', + ...maintenanceFooter(maintenance), + ].join('\n') +} + +function main(): void { + const docs = renderDocs() + if (process.argv.includes('--check')) { + const stale: string[] = [] + for (const doc of docs) { + const abs = resolve(root, doc.rel) + const committed = existsSync(abs) ? readFileSync(abs, 'utf8') : null + if (committed !== doc.content) stale.push(doc.rel) + } + if (stale.length === 0) { + console.log(`gen-doc-graphs: ${docs.length} graph doc(s) are up to date.`) + return + } + console.error(`gen-doc-graphs: stale graph doc(s): ${stale.join(', ')}. Run \`pnpm run gen-doc-graphs\` and commit the result.`) + process.exit(1) + } + + for (const doc of docs) { + mkdirSync(dirname(resolve(root, doc.rel)), { recursive: true }) + writeFileSync(resolve(root, doc.rel), doc.content) + } + console.log(`gen-doc-graphs: wrote ${docs.length} graph doc(s).`) +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index ebaf7d5db8..b84701c819 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -6,7 +6,8 @@ * these as `workspace:^` plus test-only extras, which would add noise). This * 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. + * GitHub-viewable Mermaid graph grouped by `packages/<group>/` plus a + * dependency table. * * The file is fully generated — never hand-edit it. Output is deterministic * (packages and edges sorted) so a regenerate-and-diff freshness check is @@ -17,8 +18,8 @@ * is stale (CI / pre-push gate) */ +import { dirname, resolve } from 'node:path' import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/module-graph.md' @@ -27,10 +28,30 @@ const SCOPE = '@deepseek-ai/dsh-' interface Pkg { /** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */ short: string + /** Package group from `packages/<group>/<pkg>`. */ + group: string + /** Repo-relative package directory. */ + rel: string /** Short names of this package's in-repo peer dependencies, sorted. */ deps: string[] } +const GROUP_ORDER = [ + 'util', + 'llm', + 'core', + 'bash', + 'fs', + 'compact', + 'subagent', + 'web', + 'todo', + 'hooks', + 'session-persistence', + 'support', + 'ui', +] + /** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */ function collect(): Pkg[] { const pkgs: Pkg[] = [] @@ -44,7 +65,9 @@ function collect(): Pkg[] { .filter(d => d.startsWith(SCOPE)) .map(d => d.slice(SCOPE.length)) .sort() - pkgs.push({ short: json.name.slice(SCOPE.length), deps }) + const [, group, leaf] = rel.split('/') + if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`) + pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps }) } return topoSort(pkgs) } @@ -63,7 +86,7 @@ function topoSort(pkgs: Pkg[]): Pkg[] { while (remaining.size > 0) { const ready = [...remaining.values()] .filter(p => p.deps.every(d => placed.has(d))) - .sort((a, b) => a.short.localeCompare(b.short)) + .sort(comparePackages) if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`) for (const p of ready) { out.push(p) @@ -74,28 +97,71 @@ function topoSort(pkgs: Pkg[]): Pkg[] { return out } +function comparePackages(a: Pkg, b: Pkg): number { + const groupA = GROUP_ORDER.indexOf(a.group) + const groupB = GROUP_ORDER.indexOf(b.group) + const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA + const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB + return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short) +} + +function nodeId(prefix: string, value: string): string { + return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}` +} + +function escLabel(value: string): string { + return value.replace(/"/g, '\\"') +} + +function packageLink(pkg: Pkg): string { + return `[\`${pkg.short}\`](../${pkg.rel})` +} + /** Render the full docs/module-graph.md content (pure, deterministic). */ function render(pkgs: Pkg[]): string { const edges: string[] = [] for (const p of pkgs) { - for (const d of p.deps) edges.push(` ${p.short} --> ${d}`) + for (const d of p.deps) edges.push(` ${nodeId('pkg', p.short)} --> ${nodeId('pkg', d)}`) } - const rows = pkgs.map(p => `| \`${p.short}\` | ${p.deps.length ? p.deps.map(d => `\`${d}\``).join(', ') : '—'} |`) + const byShort = new Map(pkgs.map(pkg => [pkg.short, pkg])) + const groups = [...new Set(pkgs.map(pkg => pkg.group))].sort((a, b) => { + const ia = GROUP_ORDER.indexOf(a) + const ib = GROUP_ORDER.indexOf(b) + const na = ia === -1 ? Number.MAX_SAFE_INTEGER : ia + const nb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib + return na - nb || a.localeCompare(b) + }) + const groupBlocks: string[] = [] + for (const group of groups) { + groupBlocks.push(` subgraph ${nodeId('group', group)}["packages/${escLabel(group)}"]`) + for (const pkg of pkgs.filter(p => p.group === group).sort((a, b) => a.short.localeCompare(b.short))) { + groupBlocks.push(` ${nodeId('pkg', pkg.short)}["${escLabel(pkg.short)}"]`) + } + groupBlocks.push(' end') + } + const rows = pkgs.map((p) => { + const deps = p.deps.length ? p.deps.map((d) => { + const dep = byShort.get(d) + return dep ? packageLink(dep) : `\`${d}\`` + }).join(', ') : '—' + return `| ${packageLink(p)} | \`${p.group}\` | ${deps} |` + }) return [ '<!-- Generated by scripts/gen-module-graph.ts — do not edit by hand.', ' Run `pnpm run gen-module-graph` to regenerate. -->', '', '# Module dependency graph', '', - 'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal). An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', + 'Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, derived from each package\'s `peerDependencies` (the canonical runtime-dependency signal) and grouped by the `packages/<group>/<pkg>` hierarchy. An edge `a --> b` means package `a` depends on package `b`. Names have the `@deepseek-ai/dsh-` prefix stripped.', '', '```mermaid', - 'graph TD', + 'flowchart TD', + ...groupBlocks, ...edges, '```', '', - '| Package | Depends on |', - '| --- | --- |', + '| Package | Group | Depends on |', + '| --- | --- | --- |', ...rows, '', ].join('\n') diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts new file mode 100644 index 0000000000..61e70d4f97 --- /dev/null +++ b/scripts/gen-persistence-catalog.ts @@ -0,0 +1,456 @@ +/** + * Generate (and verify) the persistence log event catalog in + * docs/persistence-catalog/log-events.md. + * + * The catalog is the ON-DISK-vocabulary reference: every event type that can + * appear in a session's durable event log — every member of the + * merge-extensible `SessionEventMap`, across the owning declaration in + * `@deepseek-ai/dsh-session` and every plugin declaration merge. It complements + * the cordis events/services catalog (the live bus wiring — a log event is NOT + * a cordis event; it reaches listeners via the single `session/event` emit) and + * the core-data-structures session page (the `SessionEvent` envelope and + * derivation semantics): this page is the RECORDS a persisted log can contain. + * + * `tsx scripts/gen-persistence-catalog.ts` → write the catalog + * `tsx scripts/gen-persistence-catalog.ts --check` → exit 1 if the committed + * file is stale (CI / + * pre-push gate) + * + * Like its AST sibling `gen-cordis-catalog.ts` (and unlike the boot-based + * `gen-tool-catalog.ts`), this is a pure source pass: every log event is a + * string-literal-named property with a static type annotation, so the AST is + * the whole truth and a brand-new event (core or merged) appears in the next + * regenerate — an un-regenerated file fails `--check`. The walk enforces JSDoc + * COMPLETENESS on the whole vocabulary: every member carries description prose + * (it becomes the catalog entry), and an `@mode` tag on a member is a hard + * error — dispatch modes belong to cordis bus events, and a log event has none + * (see docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md). + * Structural holes are hard errors for the same reason: a member that is not a + * property signature with an explicit payload type, an `extends` clause on a + * declaration, a top-level `interface SessionEventMap` that is not the single + * exported declaration in the owning package, and a duplicate declaration of + * one event would each let something join (or impersonate) + * `keyof SessionEventMap` without a truthful catalog row. Violations aggregate + * into ONE error listing every offender. + * + * The surface/log-only badge is parsed from the `SurfaceEventType` union in the + * owning package (never hand-listed here), and every union member must name a + * collected event — a stale union member is a hard error. + * + * Payload fences use the ` ```ts persistence-catalog ` info string: + * doc-typecheck recognizes it and skips compilation (a bare payload fragment is + * not standalone-compilable), excluded from the opt-out ratio. + */ + +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/persistence-catalog/log-events.md' + +/** The fenced-block info string for generated payload blocks (skipped by + * doc-typecheck, since a bare payload fragment is not standalone-compilable). */ +const FENCE = 'ts persistence-catalog' + +/** The package whose module id plugin merges augment (`declare module '…'`). */ +const SESSION_MODULE = '@deepseek-ai/dsh-session' + +/** + * Cross-link map: a type name that appears in a payload → the + * core-data-structures page that documents it (path relative to OUT's folder). + * Hand-curated and catalog-owned, same policy as the cordis catalog's map: each + * name resolves to exactly one PRIMARY page. A payload type with no + * core-data-structures home (e.g. `HookDialect`, documented in its package) + * simply gets no link. + */ +const LINK_MAP: Record<string, string> = { + CallId: 'core.md', + ContentBlock: 'core.md', + MessageSource: 'core.md', + StreamChunk: 'llm-streaming.md', + TokenUsage: 'llm-streaming.md', + TodoItem: 'session.md', + TurnTrigger: 'session.md', + TurnEndReason: 'session.md', +} + +/** One log event, extracted from a `SessionEventMap` declaration. */ +export interface LogEventEntry { + /** Scoped name, e.g. `turn/start`. */ + name: string + /** The scope prefix, e.g. `turn` (everything before the first `/`). */ + scope: string + /** Payload type text (the member's type annotation, whitespace-collapsed). */ + payload: string + /** Description prose (the member's JSDoc), one line per paragraph. */ + doc: string + /** Source pointer `packages/…/file.ts:line` of the declaration. */ + source: string +} + +/** A {@link LogEventEntry} plus its surface-eligibility badge. */ +export interface AnnotatedLogEventEntry extends LogEventEntry { + /** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */ + surface: boolean +} + +/** 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}` +} + +const printer = ts.createPrinter({ removeComments: true }) + +/** + * One-line payload text for a member's type annotation. Printed through the + * TypeScript printer (not sliced from source text): the printer emits `;` + * member separators regardless of how the source separated them, so a + * multi-line newline-separated type literal still collapses to a VALID + * single-line fragment. The trailing `;` the printer puts before every `}` is + * dropped to match the repo's inline-literal style. + */ +function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string { + return printer.printNode(ts.EmitHint.Unspecified, type, sf) + .replace(/\s+/g, ' ') + .replace(/;\s*\}/g, ' }') + .trim() +} + +/** 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, flagging whether any `@mode` + * tag is present (forbidden on log events). 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`. Description prose ends at the FIRST block tag (standard + * JSDoc semantics): tag lines and their continuation lines are never prose. + */ +function parseJsDoc(raw: string): { doc: string; hasMode: boolean } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + let hasMode = false + let inTags = false + 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) { + // Tag detection runs on the trimmed line: the normalization above strips at + // most one post-`*` space, so an extra-indented `* @mode` still reaches + // here with leading whitespace and must not leak into prose. + const tagLine = line.trimStart() + if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue } + if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue } + if (inTags) continue // block-tag territory: continuations are never prose + if (line.trim() === '') { flushPara(); continue } + if (/^-\s+/.test(line)) { + // A list item starts: a pending paragraph (e.g. an intro line directly + // 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, hasMode } +} + +/** + * Throw one aggregate error for every completeness violation a walk collected. + * Aggregation is deliberate: a remediation pass sees the whole list at once + * instead of replaying the gate once per offender. + */ +function reportViolations(violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `gen-persistence-catalog: ${violations.length} JSDoc completeness violation(s):\n` + + violations.map(v => ` ${v}`).join('\n'), + ) +} + +/** + * Every `interface SessionEventMap` declaration in a source file: the owning + * top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration + * merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms + * declare members of the SAME merged interface, so both are catalogued + * uniformly. `topLevel` distinguishes the owning form so the caller can verify + * it actually lives in the owning package — an unrelated local interface that + * happens to share the name must not be catalogued as the on-disk vocabulary. + */ +function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] { + const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = [] + for (const stmt of sf.statements) { + if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true }) + if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_MODULE + && stmt.body && ts.isModuleBlock(stmt.body)) { + for (const inner of stmt.body.statements) { + if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false }) + } + } + } + return decls +} + +/** + * The npm package name owning a `packages/<group>/<pkg>/…` source file, read + * from that package's manifest — or null when the manifest is missing or + * unparseable (the caller treats null as "ownership unverifiable"). + */ +function packageNameFor(rel: string, scanRoot: string): string | null { + const dir = rel.split('/').slice(0, 3).join('/') + try { + const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string } + return typeof manifest.name === 'string' ? manifest.name : null + } catch { + // Missing or malformed package.json — every real workspace package has one, + // so this only arises in stripped-down fixture trees; either way ownership + // cannot be verified and the caller reports the declaration. + return null + } +} + +/** + * Walk every `SessionEventMap` declaration (the owning interface plus every + * plugin declaration merge) and extract its events, hard-erroring (aggregated) + * on any completeness violation: a member without description prose, an + * `@mode` tag (a category error — log events have no dispatch mode), a member + * that is not a property signature with an explicit payload type, a + * non-literal member name, an `extends` clause (inherited keys would join + * `keyof SessionEventMap` without a catalog row), a top-level declaration that + * is not the single exported one in the owning package, or the same event + * declared twice. + * `scanRoot` defaults to the repo root; tests pass a fixture dir. + */ +export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { + const entries: LogEventEntry[] = [] + const violations: string[] = [] + const seen = new Map<string, string>() + let owningDecl: string | null = null + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('SessionEventMap')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + for (const { decl, topLevel } of sessionEventMapDecls(sf)) { + const declSrc = pointer(rel, sf, decl) + if (topLevel) { + // The top-level form is the OWNING vocabulary, and it has exactly one + // home: the single EXPORTED declaration in the owning package. A + // same-named interface anywhere else — another package, a non-exported + // local, a second exported copy — is a different type that must not be + // catalogued as on-disk events. + const pkg = packageNameFor(rel, scanRoot) + if (pkg !== SESSION_MODULE) { + violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`) + continue + } + const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false + if (!exported) { + violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`) + continue + } + if (owningDecl) { + violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`) + continue + } + owningDecl = declSrc + } + if (decl.heritageClauses?.length) { + violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`) + } + for (const member of decl.members) { + const src = pointer(rel, sf, member) + if (!ts.isPropertySignature(member) || !member.type) { + // A method-form or type-less member still joins `keyof SessionEventMap`, + // so skipping it silently would be exactly the undocumented-event hole + // this catalog exists to close. + const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ') + violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`) + continue + } + if (!ts.isStringLiteral(member.name)) { + violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`) + continue + } + const name = member.name.text + const where = `log event '${name}' (${src})` + const prior = seen.get(name) + if (prior) { + violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`) + continue + } + seen.set(name, src) + const payload = payloadText(member.type, sf) + const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member)) + if (hasMode) { + violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`) + } + if (!doc) { + violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`) + } + entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src }) + } + } + } + reportViolations(violations) + return entries +} + +/** + * Parse the `SurfaceEventType` union — the surface-eligible subset of event + * types — from source. Hard-errors when the alias is missing, declared more + * than once, or contains a non-string-literal member: the badge derivation + * relies on the union being a closed set of literal event names. + * `scanRoot` defaults to the repo root; tests pass a fixture dir. + */ +export function collectSurfaceEventTypes(scanRoot: string = root): string[] { + const found: { names: string[]; source: string }[] = [] + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('SurfaceEventType')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + for (const stmt of sf.statements) { + if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue + const src = pointer(rel, sf, stmt) + const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type] + const names: string[] = [] + for (const m of members) { + if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text) + else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`) + } + found.push({ names, source: src }) + } + } + const only = found[0] + if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.') + if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`) + return only.names +} + +/** + * Attach the surface/log-only badge to each event. Hard-errors when a + * `SurfaceEventType` union member names no collected event — a stale union + * member would otherwise silently badge nothing. + */ +export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] { + const names = new Set(events.map(e => e.name)) + const stale = surfaceTypes.filter(t => !names.has(t)) + if (stale.length > 0) { + throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`) + } + const surface = new Set(surfaceTypes) + return events.map(e => ({ ...e, surface: surface.has(e.name) })) +} + +/** Render the cross-link "Types:" line for a payload, or '' if none apply. */ +function typeLinks(payload: string): string { + const seen = new Set<string>() + for (const name of Object.keys(LINK_MAP)) { + if (new RegExp(`\\b${name}\\b`).test(payload)) 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 log event entry. */ +function renderEvent(e: AnnotatedLogEventEntry): string[] { + const out = [`#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, ''] + if (e.doc) out.push(e.doc, '') + out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '') + const links = typeLinks(e.payload) + if (links) out.push(links, '') + out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') + return out +} + +/** Render the full catalog (pure, deterministic given the collected inputs). */ +export function render(events: AnnotatedLogEventEntry[]): string { + const lines: string[] = [ + '<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.', + ' Run `pnpm run gen-persistence-catalog` to regenerate. -->', + '', + '# Persistence Log Event Catalog', + '', + 'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](../core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](../core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](../cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', + '', + 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](../rfc/implemented/process/2026-07-04-persistence-log-catalog.md).', + '', + 'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](../core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](../core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + '', + '## Events', + '', + ] + 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)) + } + } + return lines.join('\n') +} + +/** CLI entry: default writes the catalog, `--check` fails if the committed copy + * is 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(annotateSurface(collectLogEvents(), collectSurfaceEventTypes())) + 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-persistence-catalog: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-persistence-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/gen-rfc-index.ts b/scripts/gen-rfc-index.ts new file mode 100644 index 0000000000..37ad96d73a --- /dev/null +++ b/scripts/gen-rfc-index.ts @@ -0,0 +1,36 @@ +/** + * Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the + * RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and + * rendering rules). The whole file is generated state; the curated prose lives + * in `docs/rfc/README.md`. Freshness is asserted by + * `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed + * index fails CI. + * + * Run: `pnpm run gen-rfc-index`. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts' + +const { rfcs, errors } = walkRfcTree() +if (errors.length > 0) { + console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:') + for (const e of errors) console.error(` ${e}`) + process.exit(1) +} + +const indexPath = resolve(rfcRoot, 'INDEX.md') +const next = renderIndex(rfcs) +let current: string | undefined +try { + current = readFileSync(indexPath, 'utf8') +} catch { + // Missing INDEX.md is the fresh-generation case, not an error: fall through and write it. +} +if (next === current) { + console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`) +} else { + writeFileSync(indexPath, next) + console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`) +} diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index be318f753c..c1a93f46d4 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -40,11 +40,17 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import WebService from '@deepseek-ai/dsh-web' +import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' +import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog/tools.md' @@ -69,6 +75,12 @@ interface ToolPackage { dir: string /** Repo-relative source path linked from the catalog entry. */ source: string + /** Services or owning runtime surfaces the package requires at execution time. */ + requires: string[] + /** Session events or other visible state the tools write or affect. */ + writes: string[] + /** Additional model-visible names shipped by example/app config. */ + shippedNames?: string[] /** Plug the injected seams + the tool plugin onto a context that already * carries `systemPrompt` + `tools`. */ mount: (ctx: Context) => Promise<void> @@ -92,15 +104,38 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-bash', dir: 'tool-bash', source: 'packages/bash/tool-bash/src/index.ts', + requires: ['ctx.tools', 'ctx.bash'], + writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'], async mount(ctx) { await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, + note: + 'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.', + }, + { + pkg: '@deepseek-ai/dsh-tool-fs', + dir: 'tool-fs', + source: 'packages/fs/tool-fs/src/index.ts', + requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'], + async mount(ctx) { + // The tool injects `fs`; boot the local backend to satisfy it. The schemas + // do not depend on the policy plugin (an event gate that changes behavior, + // not tool shape), so the bare provider is enough to harvest them. + await ctx.plugin(LocalFileSystem) + await ctx.plugin(ToolFs) + }, + note: + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', }, { pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', source: 'packages/subagent/tool-subagent/src/index.ts', + requires: ['ctx.tools', 'ctx.subagents'], + writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'], + shippedNames: ['subagent', 'subagent_fork'], async mount(ctx) { await ctx.plugin(SubagentService) // Register a scripted provider under the name the tool delegates to. @@ -114,9 +149,32 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-todo', dir: 'tool-todo', source: 'packages/todo/tool-todo/src/index.ts', + requires: ['ctx.tools', 'owning Agent session'], + writes: ['tool/call', 'todo/write', 'tool/result'], async mount(ctx) { await ctx.plugin(ToolTodo) }, + note: + 'todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan.', + }, + { + pkg: '@deepseek-ai/dsh-tool-web', + dir: 'tool-web', + source: 'packages/web/tool-web/src/index.ts', + requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The tools inject `web`; boot the seam plus one search and one fetch + // provider so both `web_search` and `web_fetch` register. The schemas do + // not depend on which provider backs the seam (or on it being available), + // so any registered provider is enough to harvest them. + await ctx.plugin(WebService) + await ctx.plugin(WebSearchExa) + await ctx.plugin(WebFetchLocal) + await ctx.plugin(ToolWeb) + }, + note: + 'web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps.', }, ] @@ -124,6 +182,9 @@ const TOOL_PACKAGES: ToolPackage[] = [ interface CatalogPackage { pkg: string source: string + requires: string[] + writes: string[] + shippedNames?: string[] schemas: ToolSchema[] /** A deployment note (see {@link ToolPackage.note}), rendered after the tools. */ note?: string @@ -173,7 +234,15 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES await ctx.plugin(ToolRegistry) await entry.mount(ctx) const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) - catalog.push({ pkg: entry.pkg, source: entry.source, schemas, ...entry.note !== undefined ? { note: entry.note } : {} }) + catalog.push({ + pkg: entry.pkg, + source: entry.source, + requires: entry.requires, + writes: entry.writes, + schemas, + ...entry.shippedNames !== undefined ? { shippedNames: entry.shippedNames } : {}, + ...entry.note !== undefined ? { note: entry.note } : {}, + }) } finally { await ctx.fiber.dispose() } @@ -185,12 +254,19 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES function renderTool(schema: ToolSchema, source: string): string[] { const out = [`### \`${schema.name}\``, ''] if (schema.description) out.push(schema.description, '') - if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '') out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '') out.push(`Source: [\`${source}\`](../../${source})`, '') return out } +function codeList(values: string[] | undefined): string { + return values?.length ? values.map(value => `\`${value}\``).join(', ') : '-' +} + +function tableCell(value: string | undefined): string { + return value ? value.replace(/\|/g, '\\|').replace(/\n/g, '<br>') : '-' +} + /** Render the full catalog (pure, deterministic given the manifest-ordered input). */ export function render(catalog: ToolCatalog): string { const lines: string[] = [ @@ -199,12 +275,20 @@ export function render(catalog: ToolCatalog): string { '', '# Tool Schema Catalog', '', - 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](../cordis-catalog/events.md) & [services](../cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', + '## Tool Package Map', + '', + 'This table connects model-visible tool names to the plugin package and service seams behind them. Exact JSON Schemas follow in the package sections below.', + '', + '| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |', + '| --- | --- | --- | --- | --- | --- |', + ...catalog.map(entry => `| \`${entry.pkg}\` | ${codeList(entry.schemas.map(schema => schema.name))} | ${codeList(entry.requires)} | ${codeList(entry.writes)} | ${codeList(entry.shippedNames)} | ${tableCell(entry.note)} |`), + '', ] for (const entry of catalog) { lines.push(`## \`${entry.pkg}\``, '') diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index df13d87caa..0e06372c3f 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { readdirSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' // publint every harness package. Packages live at packages/<group>/<pkg> @@ -14,6 +14,7 @@ const packages = readdirSync(packagesRoot, { withFileTypes: true }) .flatMap(group => readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) .filter(pkg => pkg.isDirectory()) + .filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json'))) .map(pkg => `packages/${group.name}/${pkg.name}`), ) diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts new file mode 100644 index 0000000000..f8cb1d763b --- /dev/null +++ b/scripts/rfc-index.ts @@ -0,0 +1,140 @@ +/** + * Shared source of truth for the RFC index: the tree walker (structure rules) + * and the README table renderer. `gen-rfc-index.ts` writes the generated + * regions; `verify-rfc-classification.ts` checks structure and asserts the + * committed regions are fresh. Pure module — no side effects on import. + * + * The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)): + * every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the + * folder IS the label, and both sets are CLOSED — extending either means + * amending this module AND the README's Classification prose. + * + * The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections + * whose rows are derived from each RFC's path (lifecycle/class), H1 (title, + * with an optional `RFC: ` prefix stripped), and filename date, sorted by date + * then filename. The curated prose lives in README.md, which carries no index + * rows at all. + */ + +import { readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { globSync } from 'node:fs' + +export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc') + +/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ +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 a README heading. */ +const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) + +/** One RFC file, as discovered by the walker. */ +export interface Rfc { + lifecycle: string + cls: string + base: string + /** Path relative to docs/rfc — the README link target. */ + rel: string + /** H1 text with any `RFC: ` prefix stripped — the README row title. */ + title: string + /** `yyyy-mm-dd` from the filename — the "First proposed" column. */ + date: string +} + +/** + * Walk the RFC tree, enforcing the structure rules. Returns every valid RFC + * plus one error string per violation (unknown lifecycle or class folder, bad + * depth, bad filename, missing/malformed H1). Callers treat a non-empty error + * list as fatal — the index is only generated from a structurally valid tree. + */ +export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { + const rfcs: Rfc[] = [] + const errors: string[] = [] + // The lifecycle set is closed too: any directory under docs/rfc/ that is not + // a known lifecycle would otherwise hold RFCs invisible to the walk below. + for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) { + if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) { + errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`) + } + } + for (const lifecycle of LIFECYCLES) { + for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) { + const segs = match.split('/') + // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). + if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue + // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC, + // indexed via its English filename; the pairing gate owns its consistency. + if (match.endsWith('.zh.md')) continue + const cls = segs[1] + const base = segs[2] + if (segs.length !== 3 || cls === undefined || base === undefined) { + errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`) + continue + } + if (!(CLASSES as readonly string[]).includes(cls)) { + errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`) + continue + } + if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) { + errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`) + continue + } + const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? '' + const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine) + if (!h1?.[1]) { + errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`) + continue + } + rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) }) + } + } + return { rfcs, errors } +} + +/** + * Render one lifecycle's section body: a `### {Class}` heading plus a + * `| Title | First proposed |` table for every non-empty class, in CLASSES + * order, rows sorted by date then filename. + */ +function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { + const sections: string[] = [] + for (const cls of CLASSES) { + const rows = rfcs + .filter(r => r.lifecycle === lifecycle && r.cls === cls) + .sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base)) + if (rows.length === 0) continue + const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n') + sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`) + } + return sections.join('\n\n') +} + +/** + * Render the complete `docs/rfc/INDEX.md` content: a generated-file banner + * followed by one `## {Lifecycle}` section per lifecycle in canonical order. + * The whole file is generated state — there is no curated region to preserve. + */ +export function renderIndex(rfcs: Rfc[]): string { + const parts = [ + '# RFC index', + '', + 'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).', + ] + for (const lifecycle of LIFECYCLES) { + parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle)) + } + return `${parts.join('\n')}\n` +} + +/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */ +export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\// diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json new file mode 100644 index 0000000000..c9a88aa4e0 --- /dev/null +++ b/scripts/translation-pairing.manifest.json @@ -0,0 +1,17 @@ +{ + "required": [ + "README.md", + "docs/development.md", + "docs/i18n/README.md", + "docs/i18n/translation-rules.md", + "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" + ], + "excluded": [ + "docs/AGENTS.md", + "docs/module-graph.md", + "docs/cordis-catalog/", + "docs/tool-catalog/", + "docs/persistence-catalog/", + "docs/i18n/terminology.md" + ] +} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c5e4c10b71..b1c3163782 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -10,10 +10,15 @@ { "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/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/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/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.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": "TodoItem", "source": "packages/core/session/src/types.ts" }, @@ -34,6 +39,8 @@ { "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/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.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" }, @@ -42,6 +49,19 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, + { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, @@ -49,6 +69,14 @@ { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" } + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, + + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" } ] } diff --git a/scripts/verify-doc-budgets.ts b/scripts/verify-doc-budgets.ts new file mode 100644 index 0000000000..1ace894410 --- /dev/null +++ b/scripts/verify-doc-budgets.ts @@ -0,0 +1,79 @@ +/** + * Doc-sync gate: enforce word-count ceilings on the standing docs that accrete + * (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the + * architecture overview grow a paragraph per PR unless something pushes back; + * this gate is the pushback — when a ceiling is hit, the fix is to relocate or + * condense per the documentation standard, not to raise the ceiling. Raising a + * ceiling is allowed but is a deliberate, reviewable manifest diff that the PR + * description must justify. + * + * Scope is deliberately NARROW: only the files listed in + * scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs, + * and package READMEs are unbudgeted — length is legitimate there (a feature + * matrix is the right kind of long), and the standard governs them through + * review, not a ceiling. + * + * The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits + * at least 5% above the doc's current size (working headroom, so routine + * wording edits pass while real growth trips the gate) and ratchets DOWN, + * keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing + * fails the gate, so a rename cannot silently orphan its budget. + * + * Words are counted `wc -w` style over the whole file (whitespace-delimited + * tokens, fenced code included) so a ceiling is reproducible with standard + * tools. This is a checker, not a formatter: it reports and never rewrites. + * + * Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every + * budgeted doc's current count vs ceiling without failing). + */ + +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +const MANIFEST_PATH = resolve(root, 'scripts/doc-budgets.manifest.json') + +/** `wc -w` equivalent: count whitespace-delimited tokens. */ +function countWords(text: string): number { + return text.split(/\s+/).filter(Boolean).length +} + +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, number> + +const listOnly = process.argv.includes('--list') +const failures: string[] = [] +const rows: string[] = [] + +for (const [path, ceiling] of Object.entries(manifest)) { + if (!Number.isInteger(ceiling) || ceiling <= 0) { + rows.push(`BAD ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`) + failures.push(`${path}: ceiling must be a positive integer, got ${ceiling}`) + continue + } + const abs = resolve(root, path) + if (!existsSync(abs)) { + rows.push(`MISS ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`) + failures.push(`${path}: budgeted file does not exist (renamed or deleted? update scripts/doc-budgets.manifest.json in the same change)`) + continue + } + const words = countWords(readFileSync(abs, 'utf8')) + rows.push(`${words <= ceiling ? 'ok ' : 'OVER'} ${String(words).padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`) + if (words > ceiling) { + failures.push(`${path}: ${words} words exceeds the ${ceiling}-word ceiling — relocate or condense per docs/AGENTS.md (raising the ceiling requires justification in the PR)`) + } +} + +if (listOnly) { + console.log(rows.join('\n')) + process.exit(0) +} + +if (failures.length > 0) { + console.error('verify-doc-budgets failed:\n') + for (const failure of failures) console.error(` ${failure}`) + console.error('\nSee docs/AGENTS.md for the documentation standard and the relocation-first rule.') + process.exit(1) +} + +console.log(`verify-doc-budgets: ${Object.keys(manifest).length} budgeted docs within ceiling.`) diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index 322368b724..a53399a272 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -2,7 +2,7 @@ * 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 + * `docs/architecture.md § Where New Behavior Goes`. `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)) @@ -12,7 +12,7 @@ * 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/<path>.md` tokens and * REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`, - * `docs/architecture.md § plugin checklist` — the section suffix is outside the + * `docs/architecture.md § Where New Behavior Goes` — 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. @@ -43,7 +43,7 @@ const isExcluded = (p: string): boolean => * 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. + * so trailing prose (`… .md § Where New Behavior Goes`) is not swallowed into the path. */ const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index cbd913d5ca..2a96cfd0af 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -48,6 +48,7 @@ const root = resolve(import.meta.dirname, '..') */ const PATTERNS = [ 'README.md', + 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index f8acb26d78..3ffad3be43 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -1,6 +1,6 @@ /** * Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention - * (AGENTS.md § Type Safety and Documentation) — prose paragraphs are written as + * (docs/AGENTS.md § Writing rules) — prose paragraphs are written as * one physical line per paragraph and the editor soft-wraps. A hard-wrapped * paragraph (a one-word edit reflows and re-diffs the whole block) is a defect * this script catches before review. @@ -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', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'README.zh.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-mermaid.ts b/scripts/verify-mermaid.ts new file mode 100644 index 0000000000..954c246640 --- /dev/null +++ b/scripts/verify-mermaid.ts @@ -0,0 +1,108 @@ +/** + * Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's + * own parser. Markdown link/type/code gates can say a diagram block exists and + * is linked, but only Mermaid can catch syntax errors that GitHub would fail to + * render. + * + * Scope matches the Markdown link gate so any Mermaid diagram in repo-authored + * docs is checked: README.md, README.zh.md, docs/** /*.md, + * packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md, + * packages/AGENTS.md, and .agents/skills/** /*.md. + * + * Run: `tsx scripts/verify-mermaid.ts`. + */ + +import { readFileSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { glob } from 'node:fs/promises' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import { JSDOM } from 'jsdom' +import type { Nodes } from 'mdast' + +const root = resolve(import.meta.dirname, '..') + +const PATTERNS = [ + 'README.md', + 'README.zh.md', + 'docs/**/*.md', + 'packages/*/*.md', + 'packages/*/*/*.md', + 'examples/**/*.md', + 'AGENTS.md', + 'packages/AGENTS.md', + '.agents/skills/**/*.md', +] + +interface Block { + file: string + line: number + source: string +} + +interface Violation { + file: string + line: number + message: string +} + +function extractMermaidBlocks(file: string): Block[] { + const source = readFileSync(resolve(root, file), 'utf8') + const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const out: Block[] = [] + const visit = (node: Nodes): void => { + if (node.type === 'code' && node.lang === 'mermaid') { + out.push({ file, line: node.position?.start.line ?? 0, source: node.value }) + } + if ('children' in node) { + for (const child of node.children) visit(child) + } + } + visit(tree) + return out +} + +function formatError(error: unknown): string { + if (error instanceof Error) return error.message.replace(/\s+/g, ' ').trim() + return String(error).replace(/\s+/g, ' ').trim() +} + +const blocks: Block[] = [] +const seen = new Set<string>() +let checkedFiles = 0 +for (const pattern of PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) { + const real = realpathSync(resolve(root, match)) + if (seen.has(real)) continue + seen.add(real) + checkedFiles++ + blocks.push(...extractMermaidBlocks(match)) + } +} + +const violations: Violation[] = [] +const { window } = new JSDOM('') +Object.defineProperty(globalThis, 'window', { value: window }) +Object.defineProperty(globalThis, 'document', { value: window.document }) +Object.defineProperty(globalThis, 'navigator', { value: window.navigator }) +const mermaid = (await import('mermaid')).default +mermaid.initialize({ startOnLoad: false }) +for (const block of blocks) { + try { + await mermaid.parse(block.source, { suppressErrors: false }) + } catch (error: unknown) { + violations.push({ file: block.file, line: block.line, message: formatError(error) }) + } +} + +if (violations.length === 0) { + console.log(`verify-mermaid: ${blocks.length} mermaid block(s) parsed across ${checkedFiles} file(s).`) + process.exit(0) +} + +console.error('verify-mermaid: Mermaid syntax errors found:') +for (const violation of violations) { + console.error(` ${violation.file}:${violation.line} ${violation.message}`) +} +process.exit(1) diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 011ce9591a..2e270c17f8 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,155 +1,57 @@ /** * Doc-sync gate: enforce the RFC classification scheme - * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)). + * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) + * and the freshness of the generated index + * ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)). * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the * folder IS the label. This gate is the machine source of truth for the closed - * class set and keeps the README index honest. + * class set and keeps the generated index honest. * - * Two checks: + * Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker + * and renderer): * * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder - * from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a - * lifecycle root (other than the README/AGENTS allowlist) fails; an unknown - * class folder fails; a stray file at an unexpected depth fails. This is what - * makes the set CLOSED: a new class folder can't appear without amending - * CLASSES here (and the README's Classification section, per the RFC). - * - * 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. + * from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1. + * A loose `.md` directly under a lifecycle root (other than the + * README/AGENTS allowlist) fails; an unknown class folder fails; a stray + * file at an unexpected depth fails. This is what makes the set CLOSED: a + * new class folder can't appear without amending CLASSES (and the README's + * Classification section, per the RFC). + * 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render + * from the tree, so every RFC is listed exactly once, under the heading + * matching its path, with its H1 title and filename date. The fix for a + * stale index is `pnpm run gen-rfc-index`, never a hand edit. This is + * checker, not fixer: it reports and never rewrites. + * 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no + * index-shaped table rows; the list lives only in the generated INDEX.md. * * Run: `tsx scripts/verify-rfc-classification.ts`. */ import { readFileSync } from 'node:fs' -import { relative, resolve } from 'node:path' -import { glob } from 'node:fs/promises' +import { resolve } from 'node:path' +import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts' -const root = resolve(import.meta.dirname, '..') -const rfcRoot = resolve(root, 'docs/rfc') +const { rfcs, errors } = walkRfcTree() -/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ -const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const - -/** - * The closed set of RFC classes (nested folder under each lifecycle). Adding a - * class is a deliberate act: extend this list AND the README's Classification - * section. The gate rejects any folder not listed here. - */ -const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const - -/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */ -const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md']) - -/** Title-case a class/lifecycle folder name for README heading comparison. */ -const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) - -const errors: string[] = [] - -// --- Check 1: structure ----------------------------------------------------- -// Every Markdown file anywhere under a lifecycle folder, at any depth. -interface Rfc { - lifecycle: string - cls: string - base: string - /** Path relative to docs/rfc, for the README link check. */ - rel: string -} -const rfcs: Rfc[] = [] - -for (const lifecycle of LIFECYCLES) { - for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) { - const segs = match.split('/') - // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). - if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue - 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 }) +if (errors.length === 0) { + let index: string | undefined + try { + index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8') + } catch { + // A missing INDEX.md is reported below as staleness, exactly like a drifted one. } -} - -// --- Check 2: README completeness ------------------------------------------- -// Parse the index into (lifecycle, class) -> set of linked rel paths, by -// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading -// every `](path)` link target underneath. A link target is normalized to its -// path relative to docs/rfc. -const readmePath = resolve(rfcRoot, 'README.md') -const readme = readFileSync(readmePath, 'utf8') -const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l])) -const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c])) - -/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */ -const listed = new Map<string, Set<string>>() -let curLifecycle: string | null = null -let curClass: string | null = null - -for (const line of readme.split('\n')) { - const h2 = /^##\s+(.+?)\s*$/.exec(line) - if (h2?.[1] !== undefined) { - curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null - curClass = null - continue + if (renderIndex(rfcs) !== index) { + errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result') } - const h3 = /^###\s+(.+?)\s*$/.exec(line) - if (h3?.[1] !== undefined) { - curClass = classByHeading.get(h3[1].trim()) ?? null - continue - } - if (!curLifecycle || !curClass) continue - // Collect every relative .md link target on this line. - for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) { - const target = m[1] - if (target === undefined) continue - // README links are relative to docs/rfc; normalize and key by location. - const rel = relative(rfcRoot, resolve(rfcRoot, target)) - const key = `${curLifecycle}/${curClass}` - const set = listed.get(key) ?? new Set<string>() - set.add(rel) - listed.set(key, set) - } -} - -// Every on-disk RFC must be listed under the heading matching its path. -const seenOnDisk = new Set<string>() -for (const rfc of rfcs) { - seenOnDisk.add(rfc.rel) - const key = `${rfc.lifecycle}/${rfc.cls}` - if (!listed.get(key)?.has(rfc.rel)) { - errors.push( - `index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`, - ) - } -} - -// Every README entry must point at a real RFC under that same heading (catches a -// misfiled or stale row). -for (const [key, targets] of listed) { - for (const rel of targets) { - if (!seenOnDisk.has(rel)) { - errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`) + const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8') + for (const line of readme.split('\n')) { + if (INDEX_ROW.test(line)) { + errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`) } } } -// --- Report ----------------------------------------------------------------- if (errors.length === 0) { console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`) process.exit(0) diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts new file mode 100644 index 0000000000..99347e4764 --- /dev/null +++ b/scripts/verify-rfc-format.ts @@ -0,0 +1,119 @@ +/** + * Doc-sync gate: enforce the RFC in-file format + * ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in + * [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)). + * The classification gate owns WHERE a file sits and how it is named; this gate + * owns what is INSIDE: the header block, the per-lifecycle body skeleton, and + * the Alternatives-considered mandate. + * + * Per English RFC (`.zh.md` counterparts are the pairing gate's concern): + * + * 1. HEADER — line 1 is `# RFC: <title>`, line 2 blank, line 3 the one + * `Status:` line in the file, line 4 blank. The status is the dateless enum + * matching the lifecycle folder: `Status: proposed`, `Status: implemented`, + * or `Status: rejected — <reason>`. + * 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's + * required sections are present under their canonical names (`proposed/`: + * Proposal, Acceptance criteria, Risks; `implemented/`: Decision, + * Consequences; `rejected/`: Proposal); `implemented/` must not carry the + * proposal-era headings (Proposal, Plan, Migration plan, Acceptance + * criteria) that the docs standard's slop checklist outlaws there. + * 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a + * pre-format RFC (dated before the format landed) carrying the exact + * grandfather comment instead. Carrying both, or grandfathering a + * post-format RFC, fails. + * 4. DEBT MARKER — the retired legacy-format debt comment may not reappear. + * + * Checker, not fixer: it reports and never rewrites. + * Run: `tsx scripts/verify-rfc-format.ts`. + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { rfcRoot, walkRfcTree } from './rfc-index.ts' + +/** The date the format contract landed; the grandfather comment is valid only before it. */ +const FORMAT_ADOPTED = '2026-07-05' + +/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */ +const GRANDFATHER = '<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->' + +/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */ +const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format' + +/** Status-line grammar per lifecycle folder. */ +const STATUS: Record<string, RegExp> = { + proposed: /^Status: proposed$/, + implemented: /^Status: implemented$/, + rejected: /^Status: rejected — .+$/, +} + +/** Required `##` headings per lifecycle, beyond the universal `## Problem` opener. */ +const REQUIRED: Record<string, string[]> = { + proposed: ['## Proposal', '## Acceptance criteria', '## Risks'], + implemented: ['## Decision', '## Consequences'], + rejected: ['## Proposal'], +} + +/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */ +const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i + +const { rfcs, errors } = walkRfcTree() + +for (const rfc of rfcs) { + const fail = (msg: string): void => { + errors.push(`format: ${rfc.rel} — ${msg}`) + } + const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n') + // Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a + // status line, a banned heading, or the grandfather comment inside a fence + // (the README's own format section does), and only real prose counts. + let inFence = false + const prose = lines.filter((l) => { + if (l.startsWith('```')) { + inFence = !inFence + return false + } + return !inFence + }) + + if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`') + if (lines[1] !== '') fail('line 2 must be blank') + const status = STATUS[rfc.lifecycle] + if (status !== undefined && !status.test(lines[2] ?? '')) { + fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`) + } + if (lines[3] !== '') fail('line 4 must be blank') + const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2]) + if (statusLines.length > 0 || prose.filter(l => l === lines[2]).length > 1) { + fail('the line-3 `Status:` line must be the only one in the file') + } + + const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd()) + if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`) + for (const required of REQUIRED[rfc.lifecycle] ?? []) { + if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`) + } + if (rfc.lifecycle === 'implemented') { + for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) { + fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`) + } + } + + const hasSection = h2s.includes('## Alternatives considered') + const hasGrandfather = prose.includes(GRANDFATHER) + if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment') + if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)') + if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`) + + if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker') +} + +if (errors.length === 0) { + console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`) + process.exit(0) +} + +console.error('verify-rfc-format: violations found:') +for (const e of errors) console.error(` ${e}`) +process.exit(1) diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts new file mode 100644 index 0000000000..eea30e4b22 --- /dev/null +++ b/scripts/verify-translation-pairing.ts @@ -0,0 +1,335 @@ +/** + * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md). + * English and Chinese carry EQUAL authority — either language may be authored + * first — so consistency is recorded per pair in a sidecar metadata file, + * `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last + * time a human confirmed the two say the same thing: + * + * foo.md: <40-hex blob hash> + * foo.zh.md: <40-hex blob hash> + * + * The gate checks, mechanically, the checkable half of the contract: + * + * 1. Every file in the manifest's `required` list has a COMPLETE pair + * (the enforcement frontier — grows batch by batch). + * 2. Every pair that exists at all is complete and consistent: all three + * files present (a `.zh.md` or a `.i18n.yaml` without its counterparts + * is an error — pairs merge whole, never half), each side's current + * blob hash equals the recorded one (an edit to EITHER side without a + * re-confirmed counterpart goes red), both sides carry the language + * switcher, and the structural signatures match one to one — heading + * depths in order, fenced code blocks VERBATIM (info string + content), + * table column counts, list kinds, and every link target except the + * switcher itself. + * 3. `excluded` files (generated docs, agent instructions, the bilingual + * terminology table) have no `.zh.md` and no `.i18n.yaml` at all. + * + * What it deliberately does NOT check is translation quality or which side + * is "right": a green gate means the pair was confirmed consistent at these + * exact contents, not that the confirmation was sound — accuracy, + * terminology, and tone are the human reviewer's half of the contract + * (docs/i18n/translation-rules.md). + * + * Blob hashes, not commit hashes, so a pair edited in the same PR verifies + * without any history lookup: consistency is a pure content comparison, + * computed here directly (sha1 of `blob <size>\0<content>`) without spawning + * git. The recorded hash also recovers the last-confirmed text of either + * side (`git cat-file -p <hash>`) for diff-based minimal updates. + * + * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to + * print the pairing state of every in-scope document as a work list (always + * exits 0), or with `--write` to (re)record both hashes for every complete + * pair after you have brought the two sides back in line (the resulting + * yaml diff is the reviewable act of confirming consistency). + */ + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { glob } from 'node:fs/promises' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' + +const root = resolve(import.meta.dirname, '..') +const listMode = process.argv.includes('--list') +const writeMode = process.argv.includes('--write') + +/** Scope of the bilingual contract: the root README and the docs tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml'] + +/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ +interface Manifest { + required: string[] + excluded: string[] +} +const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest + +/** + * An excluded entry ending in `/` excludes the whole directory. The trailing + * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a + * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the + * manifest must keep their trailing slash. + */ +function isExcluded(file: string): boolean { + return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) +} + +/** Full git blob hash (what `git hash-object` prints). */ +function blobHash(content: Buffer): string { + const hash = createHash('sha1') + hash.update(`blob ${content.byteLength}\0`) + hash.update(content) + return hash.digest('hex') +} + +/** The three paths of a pair, derived from the English-file path. */ +function pairPaths(source: string): { zh: string; meta: string } { + return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') } +} + +const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/ + +/** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */ +function parseMeta(content: string): Map<string, string> | undefined { + const out = new Map<string, string>() + for (const line of content.split('\n')) { + if (line === '' || line.startsWith('#')) continue + const match = META_LINE.exec(line) + if (!match?.[1] || !match[2]) return undefined + out.set(match[1], match[2]) + } + return out +} + +/** Render a `foo.i18n.yaml` consistency record. */ +function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string { + return [ + '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each', + '# side as of the last confirmed-consistent state. Both languages carry equal authority;', + '# after editing either side, bring the other along and re-record with:', + '# pnpm run verify-translation-pairing --write', + `${basename(source)}: ${sourceHash}`, + `${basename(zh)}: ${zhHash}`, + '', + ].join('\n') +} + +/** + * The structural signature the two sides must share, as ordered sequences so + * a swap or a level change is caught, not just a count change. Prose is + * deliberately absent: the gate checks shape, never wording. + */ +interface Signature { + /** Heading depths in document order (h2 → 2). */ + headings: number[] + /** Fenced code blocks verbatim: info string + content, in order. */ + code: string[] + /** Column count of each table, in order. */ + tables: number[] + /** Each list's kind (ordered vs bullet), in order. */ + lists: string[] + /** Every link target in order, the language switcher's excluded. */ + links: string[] +} + +/** Whether the tree contains a link to exactly `target` (the switcher check). */ +function linksTo(tree: Nodes, target: string): boolean { + let found = false + const visit = (node: Nodes): void => { + if (node.type === 'link' && node.url === target) found = true + if ('children' in node) for (const child of node.children) visit(child) + } + visit(tree) + return found +} + +/** Collect the structural signature, skipping links to `switcherTarget`. */ +function signatureOf(tree: Nodes, switcherTarget: string): Signature { + const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] } + const visit = (node: Nodes): void => { + switch (node.type) { + case 'heading': + sig.headings.push(node.depth) + break + case 'code': + sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`) + break + case 'table': + sig.tables.push(node.children[0]?.children.length ?? 0) + break + case 'list': + sig.lists.push(node.ordered ? 'ordered' : 'bullet') + break + case 'link': + if (node.url !== switcherTarget) sig.links.push(node.url) + break + default: + // Every other node kind is prose or container — not part of the signature. + break + } + if ('children' in node) for (const child of node.children) visit(child) + } + visit(tree) + return sig +} + +/** Render a signature element for an error message, truncated for readability. */ +function show(value: string | number | undefined): string { + if (value === undefined) return 'nothing' + const text = JSON.stringify(value) + return text.length > 72 ? `${text.slice(0, 72)}…` : text +} + +/** First divergence between two signatures, as messages; empty when identical. */ +function signatureDiff(source: Signature, zh: Signature): string[] { + const out: string[] = [] + const fields: [string, (string | number)[], (string | number)[]][] = [ + ['heading (depth)', source.headings, zh.headings], + ['code block', source.code, zh.code], + ['table (column count)', source.tables, zh.tables], + ['list (kind)', source.lists, zh.lists], + ['link target', source.links, zh.links], + ] + for (const [field, s, z] of fields) { + const length = Math.max(s.length, z.length) + for (let i = 0; i < length; i++) { + if (s[i] !== z[i]) { + out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`) + break + } + } + } + return out +} + +function parse(content: string): Nodes { + return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) +} + +// Enumerate the scope once. +const files = new Set<string>() +for (const pattern of SCOPE_PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) files.add(match) +} +const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() +const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() +const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort() + +// --write: (re)record both hashes for every complete pair, creating missing records. +if (writeMode) { + let written = 0 + for (const source of sources) { + if (isExcluded(source)) continue + const { zh, meta } = pairPaths(source) + if (!existsSync(join(root, zh))) continue + const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh)))) + if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue + writeFileSync(join(root, meta), record) + console.log(`verify-translation-pairing: recorded ${meta}`) + written++ + } + console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`) + process.exit(0) +} + +const errors: string[] = [] +const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>() + +// 1. Required pairs exist. +for (const req of manifest.required) { + if (!existsSync(join(root, req))) { + errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) + continue + } + const { zh } = pairPaths(req) + if (!existsSync(join(root, zh))) { + errors.push(`${req}: required to have a translation, but ${zh} does not exist`) + state.set(req, 'missing') + } +} + +// 2. Every pair that exists at all is complete and consistent. Anchor on the +// union of .zh.md files and .i18n.yaml records so a half-deleted pair is +// caught from either remnant. +const pairAnchors = new Set<string>() +for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md')) +for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md')) + +for (const source of [...pairAnchors].sort()) { + const { zh, meta } = pairPaths(source) + const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) } + + if (isExcluded(source)) { + if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`) + if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`) + continue + } + const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta)) + if (missing.length > 0) { + errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`) + continue + } + + const sourceContent = readFileSync(join(root, source)) + const zhContent = readFileSync(join(root, zh)) + const record = parseMeta(readFileSync(join(root, meta), 'utf8')) + if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) { + errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`) + continue + } + + let consistent = true + for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) { + const current = blobHash(content) + if (record.get(basename(file)) !== current) { + errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`) + consistent = false + } + } + if (!consistent) { + state.set(source, 'out-of-sync') + continue + } + + const sourceTree = parse(sourceContent.toString('utf8')) + const zhTree = parse(zhContent.toString('utf8')) + if (!linksTo(zhTree, basename(source))) { + errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) + } + if (!linksTo(sourceTree, basename(zh))) { + errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) + } + for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) { + errors.push(`${source} ↔ ${zh}: ${divergence}`) + } + if (!state.has(source)) state.set(source, 'ok') +} + +// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog. +for (const source of sources) { + if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing') +} + +if (listMode) { + const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const + const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) + for (const [file, status] of rows) { + const required = manifest.required.includes(file) + console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`) + } + const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } + for (const status of state.values()) counts[status]++ + console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`) + process.exit(0) +} + +if (errors.length === 0) { + console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`) + process.exit(0) +} + +console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +for (const message of errors) console.error(` ${message}`) +process.exit(1) diff --git a/tsconfig.base.json b/tsconfig.base.json index 787d983dbf..efc82efce2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,10 +43,13 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", "./packages/session-fork/*/src", + "./packages/web/*/src", "./packages/todo/*/src", + "./packages/hooks/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 8c8c59903b..c55d401f13 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -29,11 +29,21 @@ { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/fs/fs" }, + { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/tool-fs" }, + { "path": "./packages/web/web" }, + { "path": "./packages/web/web-search-exa" }, + { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-search-deepseek" }, + { "path": "./packages/web/web-fetch-local" }, + { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/stdio-agent" }, - { "path": "./packages/support/ui-stdio" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, @@ -42,6 +52,9 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, - { "path": "./packages/todo/tool-todo" } + { "path": "./packages/todo/tool-todo" }, + { "path": "./packages/hooks/hook-protocol" }, + { "path": "./packages/hooks/hooks-claude" }, + { "path": "./packages/hooks/hooks-codex" } ] } diff --git a/tsconfig.json b/tsconfig.json index 46859a8506..100994fb9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,14 +37,24 @@ { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/fs/fs" }, + { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/session-fork/session-fork" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/web/web" }, + { "path": "./packages/web/web-search-exa" }, + { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-search-deepseek" }, + { "path": "./packages/web/web-fetch-local" }, + { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/stdio-agent" }, - { "path": "./packages/support/ui-stdio" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, @@ -53,6 +63,9 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, - { "path": "./packages/todo/tool-todo" } + { "path": "./packages/todo/tool-todo" }, + { "path": "./packages/hooks/hook-protocol" }, + { "path": "./packages/hooks/hooks-claude" }, + { "path": "./packages/hooks/hooks-codex" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index fd37a67278..6afd87dcb0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,9 +32,10 @@ export default defineConfig({ // 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). + // 100% or it doesn't merge (docs/testing.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. + // Every v8 ignore comment must carry a reason — see the quality-gates RFC + // (docs/rfc/implemented/process/2026-06-11-quality-gates.md). thresholds: { perFile: true, statements: 100,